| /** |
| * @license |
| * Copyright 2016 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| /* NB: Order is important, because of namespaced classes. */ |
| |
| import {GrEtagDecorator} from '../../elements/shared/gr-rest-api-interface/gr-etag-decorator'; |
| import { |
| FetchJSONRequest, |
| FetchParams, |
| FetchPromisesCache, |
| GrRestApiHelper, |
| parsePrefixedJSON, |
| readResponsePayload, |
| SendJSONRequest, |
| SendRequest, |
| SiteBasedCache, |
| } from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper'; |
| import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser'; |
| import {parseDate} from '../../utils/date-util'; |
| import {getBaseUrl} from '../../utils/url-util'; |
| import {getAppContext} from '../app-context'; |
| import {Finalizable} from '../registry'; |
| import {getParentIndex, isMergeParent} from '../../utils/patch-set-util'; |
| import { |
| ListChangesOption, |
| listChangesOptionsToHex, |
| } from '../../utils/change-util'; |
| import {assertNever, hasOwnProperty} from '../../utils/common-util'; |
| import {AuthRequestInit, AuthService} from '../gr-auth/gr-auth'; |
| import { |
| AccountCapabilityInfo, |
| AccountDetailInfo, |
| AccountExternalIdInfo, |
| AccountId, |
| AccountInfo, |
| ActionNameToActionInfoMap, |
| Base64File, |
| Base64FileContent, |
| Base64ImageFile, |
| BasePatchSetNum, |
| BlameInfo, |
| BranchInfo, |
| BranchInput, |
| BranchName, |
| CapabilityInfoMap, |
| ChangeId, |
| ChangeInfo, |
| ChangeMessageId, |
| ChangeViewChangeInfo, |
| CommentInfo, |
| CommentInput, |
| CommitId, |
| CommitInfo, |
| ConfigInfo, |
| ConfigInput, |
| ContributorAgreementInfo, |
| ContributorAgreementInput, |
| DashboardId, |
| DashboardInfo, |
| DeleteDraftCommentsInput, |
| DiffPreferenceInput, |
| DocResult, |
| EditInfo, |
| EDIT, |
| EditPreferencesInfo, |
| EmailAddress, |
| EmailInfo, |
| EncodedGroupId, |
| FileNameToFileInfoMap, |
| FilePathToDiffInfoMap, |
| FixId, |
| GitRef, |
| GpgKeyId, |
| GpgKeyInfo, |
| GpgKeysInput, |
| GroupAuditEventInfo, |
| GroupId, |
| GroupInfo, |
| GroupInput, |
| GroupName, |
| GroupNameToGroupInfoMap, |
| GroupOptionsInput, |
| Hashtag, |
| HashtagsInput, |
| ImagesForDiff, |
| IncludedInInfo, |
| MergeableInfo, |
| NameToProjectInfoMap, |
| NumericChangeId, |
| PARENT, |
| ParsedJSON, |
| Password, |
| PatchRange, |
| PatchSetNum, |
| PathToCommentsInfoMap, |
| PathToRobotCommentsInfoMap, |
| PluginInfo, |
| PreferencesInfo, |
| PreferencesInput, |
| ProjectAccessInfo, |
| ProjectAccessInfoMap, |
| ProjectAccessInput, |
| ProjectInfo, |
| ProjectInfoWithName, |
| ProjectInput, |
| ProjectWatchInfo, |
| RelatedChangesInfo, |
| RepoName, |
| RequestPayload, |
| ReviewInput, |
| RevisionId, |
| ServerInfo, |
| SshKeyInfo, |
| SubmittedTogetherInfo, |
| SuggestedReviewerInfo, |
| TagInfo, |
| TagInput, |
| TopMenuEntryInfo, |
| UrlEncodedCommentId, |
| } from '../../types/common'; |
| import { |
| DiffInfo, |
| DiffPreferencesInfo, |
| IgnoreWhitespaceType, |
| } from '../../types/diff'; |
| import { |
| CancelConditionCallback, |
| GetDiffCommentsOutput, |
| GetDiffRobotCommentsOutput, |
| RestApiService, |
| } from './gr-rest-api'; |
| import { |
| CommentSide, |
| createDefaultDiffPrefs, |
| createDefaultEditPrefs, |
| createDefaultPreferences, |
| HttpMethod, |
| ReviewerState, |
| } from '../../constants/constants'; |
| import {firePageError, fireServerError} from '../../utils/event-util'; |
| import {ParsedChangeInfo} from '../../types/types'; |
| import {ErrorCallback} from '../../api/rest'; |
| import {addDraftProp, DraftInfo} from '../../utils/comment-util'; |
| import {BaseScheduler} from '../scheduler/scheduler'; |
| import {MaxInFlightScheduler} from '../scheduler/max-in-flight-scheduler'; |
| |
| const MAX_PROJECT_RESULTS = 25; |
| |
| 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]: Promise<RepoName | undefined>} = {}; // Shared across instances. |
| |
| function suppress404s(res?: Response | null) { |
| if (!res || res.status === 404) return; |
| // This is the default error handling behavior of the rest-api-helper. |
| fireServerError(res); |
| } |
| |
| 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; |
| p?: string; |
| } |
| |
| interface QuerySuggestedReviewersParams { |
| [paramName: string]: string | undefined | null | number; |
| n: number; |
| q?: string; |
| 'reviewer-state': ReviewerState; |
| } |
| |
| interface GetDiffParams { |
| [paramName: string]: string | undefined | null | number | boolean; |
| intraline?: boolean | null; |
| whitespace?: IgnoreWhitespaceType; |
| parent?: number; |
| base?: PatchSetNum; |
| } |
| |
| type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest; |
| |
| export function _testOnlyResetGrRestApiSharedObjects() { |
| siteBasedCache = new SiteBasedCache(); |
| fetchPromisesCache = new FetchPromisesCache(); |
| pendingRequest = {}; |
| grEtagDecorator = new GrEtagDecorator(); |
| projectLookup = {}; |
| getAppContext().authService.clearCache(); |
| } |
| |
| function createReadScheduler() { |
| return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 10); |
| } |
| |
| function createWriteScheduler() { |
| return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5); |
| } |
| export class GrRestApiServiceImpl implements RestApiService, Finalizable { |
| readonly _cache = siteBasedCache; // Shared across instances. |
| |
| readonly _sharedFetchPromises = fetchPromisesCache; // Shared across instances. |
| |
| readonly _pendingRequests = pendingRequest; // Shared across instances. |
| |
| readonly _etags = grEtagDecorator; // Shared across instances. |
| |
| readonly _projectLookup = projectLookup; // Shared across instances. |
| |
| // The value is set in created, before any other actions |
| private authService: AuthService; |
| |
| // The value is set in created, before any other actions |
| private readonly _restApiHelper: GrRestApiHelper; |
| |
| constructor(authService?: AuthService) { |
| // TODO: Make the authService constructor parameter required when we have |
| // changed all usages of this class to not instantiate via createElement(). |
| this.authService = authService ?? getAppContext().authService; |
| this._restApiHelper = new GrRestApiHelper( |
| this._cache, |
| this.authService, |
| this._sharedFetchPromises, |
| createReadScheduler(), |
| createWriteScheduler() |
| ); |
| } |
| |
| finalize() {} |
| |
| _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> { |
| // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend |
| // supports it. |
| const url = `/projects/${encodeURIComponent(repo)}/config`; |
| this._cache.delete(url); |
| return this._restApiHelper.send({ |
| method: HttpMethod.PUT, |
| url, |
| body: config, |
| anonymizedUrl: '/projects/*/config', |
| }); |
| } |
| |
| runRepoGC(repo: RepoName): Promise<Response> { |
| // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend |
| // supports it. |
| const encodeName = encodeURIComponent(repo); |
| return this._restApiHelper.send({ |
| method: HttpMethod.POST, |
| url: `/projects/${encodeName}/gc`, |
| body: '', |
| anonymizedUrl: '/projects/*/gc', |
| }); |
| } |
| |
| createRepo(config: ProjectInput & {name: RepoName}): Promise<Response> { |
| // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend |
| // supports it. |
| const encodeName = encodeURIComponent(config.name); |
| return this._restApiHelper.send({ |
| method: HttpMethod.PUT, |
| url: `/projects/${encodeName}`, |
| body: config, |
| anonymizedUrl: '/projects/*', |
| }); |
| } |
| |
| createGroup(config: GroupInput & {name: string}): Promise<Response> { |
| const encodeName = encodeURIComponent(config.name); |
| return this._restApiHelper.send({ |
| method: HttpMethod.PUT, |
| url: `/groups/${encodeName}`, |
| body: config, |
| anonymizedUrl: '/groups/*', |
| }); |
| } |
| |
| getGroupConfig( |
| group: GroupId | GroupName, |
| errFn?: ErrorCallback |
| ): Promise<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> { |
| // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend |
| // supports it. |
| const encodeName = encodeURIComponent(repo); |
| const encodeRef = encodeURIComponent(ref); |
| return this._restApiHelper.send({ |
| method: HttpMethod.DELETE, |
| url: `/projects/${encodeName}/branches/${encodeRef}`, |
| body: '', |
| anonymizedUrl: '/projects/*/branches/*', |
| }); |
| } |
| |
| deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response> { |
| // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend |
| // supports it. |
| const encodeName = encodeURIComponent(repo); |
| const encodeRef = encodeURIComponent(ref); |
| return this._restApiHelper.send({ |
| method: HttpMethod.DELETE, |
| url: `/projects/${encodeName}/tags/${encodeRef}`, |
| body: '', |
| anonymizedUrl: '/projects/*/tags/*', |
| }); |
| } |
| |
| createRepoBranch( |
| name: RepoName, |
| branch: BranchName, |
| revision: BranchInput |
| ): Promise<Response> { |
| // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend |
| // supports it. |
| const encodeName = encodeURIComponent(name); |
| const encodeBranch = encodeURIComponent(branch); |
| return this._restApiHelper.send({ |
| method: HttpMethod.PUT, |
| url: `/projects/${encodeName}/branches/${encodeBranch}`, |
| body: revision, |
| anonymizedUrl: '/projects/*/branches/*', |
| }); |
| } |
| |
| createRepoTag( |
| name: RepoName, |
| tag: string, |
| revision: TagInput |
| ): Promise<Response> { |
| // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend |
| // supports it. |
| const encodeName = encodeURIComponent(name); |
| const encodeTag = encodeURIComponent(tag); |
| return this._restApiHelper.send({ |
| method: HttpMethod.PUT, |
| url: `/projects/${encodeName}/tags/${encodeTag}`, |
| body: revision, |
| anonymizedUrl: '/projects/*/tags/*', |
| }); |
| } |
| |
| getIsGroupOwner(groupName?: GroupName): Promise<boolean> { |
| if (!groupName) return Promise.resolve(false); |
| const encodeName = encodeURIComponent(groupName); |
| const req = { |
| url: `/groups/?owned&g=${encodeName}`, |
| anonymizedUrl: '/groups/owned&g=*', |
| }; |
| return this._fetchSharedCacheURL(req).then(configs => |
| hasOwnProperty(configs, groupName) |
| ); |
| } |
| |
| getGroupMembers(groupName: GroupId | GroupName): Promise<AccountInfo[]> { |
| const encodeName = encodeURIComponent(groupName); |
| return this._restApiHelper.fetchJSON({ |
| url: `/groups/${encodeName}/members/`, |
| anonymizedUrl: '/groups/*/members', |
| }) as unknown as Promise<AccountInfo[]>; |
| } |
| |
| 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>; |
| } |
| return Promise.resolve(createDefaultDiffPrefs()); |
| }); |
| } |
| |
| 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>; |
| } |
| return Promise.resolve(createDefaultEditPrefs()); |
| }); |
| } |
| |
| savePreferences( |
| prefs: PreferencesInput |
| ): Promise<PreferencesInfo | undefined> { |
| // Note (Issue 5142): normalize the download scheme with lower case before |
| // saving. |
| if (prefs.download_scheme) { |
| prefs.download_scheme = prefs.download_scheme.toLowerCase(); |
| } |
| |
| return this._restApiHelper |
| .send({ |
| method: HttpMethod.PUT, |
| url: '/accounts/self/preferences', |
| body: prefs, |
| reportUrlAsIs: true, |
| }) |
| .then((response: Response) => |
| this.getResponseObject(response).then( |
| obj => obj as unknown as PreferencesInfo |
| ) |
| ); |
| } |
| |
| saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response> { |
| // Invalidate the cache. |
| this._cache.delete('/accounts/self/preferences.diff'); |
| return this._restApiHelper.send({ |
| method: HttpMethod.PUT, |
| url: '/accounts/self/preferences.diff', |
| body: prefs, |
| reportUrlAsIs: true, |
| }); |
| } |
| |
| saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response> { |
| // Invalidate the cache. |
| this._cache.delete('/accounts/self/preferences.edit'); |
| return this._restApiHelper.send({ |
| method: HttpMethod.PUT, |
| url: '/accounts/self/preferences.edit', |
| body: prefs, |
| reportUrlAsIs: true, |
| }); |
| } |
| |
| getAccount(): Promise<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> { |
| return this._restApiHelper.send({ |
| method: HttpMethod.PUT, |
| url: '/accounts/self/emails/' + encodeURIComponent(email), |
| anonymizedUrl: '/account/self/emails/*', |
| }); |
| } |
| |
| deleteAccountEmail(email: string): Promise<Response> { |
| return this._restApiHelper.send({ |
| method: HttpMethod.DELETE, |
| url: '/accounts/self/emails/' + encodeURIComponent(email), |
| anonymizedUrl: '/accounts/self/email/*', |
| }); |
| } |
| |
| setPreferredAccountEmail(email: string): Promise<void> { |
| // TODO(TS): add correct error handling |
| const encodedEmail = encodeURIComponent(email); |
| const req = { |
| method: HttpMethod.PUT, |
| url: `/accounts/self/emails/${encodedEmail}/preferred`, |
| anonymizedUrl: '/accounts/self/emails/*/preferred', |
| }; |
| return this._restApiHelper.send(req).then(() => { |
| // If result of getAccountEmails is in cache, update it in the cache |
| // so we don't have to invalidate it. |
| const cachedEmails = this._cache.get('/accounts/self/emails'); |
| if (cachedEmails) { |
| const emails = cachedEmails.map(entry => { |
| if (entry.email === email) { |
| return {email, preferred: true}; |
| } else { |
| return {email}; |
| } |
| }); |
| this._cache.set('/accounts/self/emails', emails); |
| } |
| }); |
| } |
| |
| _updateCachedAccount(obj: Partial<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): Promise<void> { |
| // TODO(TS): add correct error handling |
| const req: SendJSONRequest = { |
| method: HttpMethod.PUT, |
| url: '/accounts/self/name', |
| body: {name}, |
| parseResponse: true, |
| reportUrlAsIs: true, |
| }; |
| return this._restApiHelper |
| .send(req) |
| .then(newName => |
| this._updateCachedAccount({name: newName as unknown as string}) |
| ); |
| } |
| |
| setAccountUsername(username: string): Promise<void> { |
| // TODO(TS): add correct error handling |
| const req: SendJSONRequest = { |
| method: HttpMethod.PUT, |
| url: '/accounts/self/username', |
| body: {username}, |
| parseResponse: true, |
| reportUrlAsIs: true, |
| }; |
| return this._restApiHelper |
| .send(req) |
| .then(newName => |
| this._updateCachedAccount({username: newName as unknown as string}) |
| ); |
| } |
| |
| setAccountDisplayName(displayName: string): Promise<void> { |
| // TODO(TS): add correct error handling |
| const req: SendJSONRequest = { |
| method: HttpMethod.PUT, |
| url: '/accounts/self/displayname', |
| body: {display_name: displayName}, |
| parseResponse: true, |
| reportUrlAsIs: true, |
| }; |
| return this._restApiHelper.send(req).then(newName => |
| this._updateCachedAccount({ |
| display_name: newName as unknown as string, |
| }) |
| ); |
| } |
| |
| setAccountStatus(status: string): Promise<void> { |
| // TODO(TS): add correct error handling |
| const req: SendJSONRequest = { |
| method: HttpMethod.PUT, |
| url: '/accounts/self/status', |
| body: {status}, |
| parseResponse: true, |
| reportUrlAsIs: true, |
| }; |
| return this._restApiHelper |
| .send(req) |
| .then(newStatus => |
| this._updateCachedAccount({status: newStatus as unknown as string}) |
| ); |
| } |
| |
| getAccountStatus(userId: AccountId) { |
| return this._restApiHelper.fetchJSON({ |
| url: `/accounts/${encodeURIComponent(userId)}/status`, |
| anonymizedUrl: '/accounts/*/status', |
| }) as Promise<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; |
| return prefInfo; |
| }); |
| } |
| return createDefaultPreferences(); |
| }); |
| } |
| |
| getWatchedProjects() { |
| return this._fetchSharedCacheURL({ |
| url: '/accounts/self/watched.projects', |
| reportUrlAsIs: true, |
| }) as unknown as Promise<ProjectWatchInfo[] | undefined>; |
| } |
| |
| saveWatchedProjects( |
| projects: ProjectWatchInfo[] |
| ): Promise<ProjectWatchInfo[]> { |
| return this._restApiHelper.send({ |
| method: HttpMethod.POST, |
| url: '/accounts/self/watched.projects', |
| body: projects, |
| parseResponse: true, |
| reportUrlAsIs: true, |
| }) as unknown as Promise<ProjectWatchInfo[]>; |
| } |
| |
| deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response> { |
| return this._restApiHelper.send({ |
| method: HttpMethod.POST, |
| url: '/accounts/self/watched.projects:delete', |
| body: projects, |
| reportUrlAsIs: true, |
| }); |
| } |
| |
| getRequestForGetChanges( |
| changesPerPage?: number, |
| query?: string[] | string, |
| offset?: 'n,z' | number, |
| options?: string |
| ) { |
| options = options || this._getChangesOptionsHex(); |
| 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; |
| } |
| const request = { |
| url: '/changes/', |
| params, |
| reportUrlAsIs: true, |
| }; |
| return request; |
| } |
| |
| getChangesForMultipleQueries( |
| changesPerPage?: number, |
| query?: string[], |
| offset?: 'n,z' | number, |
| options?: string |
| ): Promise<ChangeInfo[][] | undefined> { |
| if (!query) return Promise.resolve(undefined); |
| |
| const request = this.getRequestForGetChanges( |
| changesPerPage, |
| query, |
| offset, |
| options |
| ); |
| |
| return Promise.resolve( |
| this._restApiHelper.fetchJSON(request, true) as Promise< |
| ChangeInfo[] | ChangeInfo[][] | undefined |
| > |
| ).then(response => { |
| if (!response) { |
| return; |
| } |
| const iterateOverChanges = (arr: ChangeInfo[]) => { |
| for (const change of arr) { |
| this._maybeInsertInLookup(change); |
| } |
| }; |
| // 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; |
| }); |
| } |
| |
| getChanges( |
| changesPerPage?: number, |
| query?: string, |
| offset?: 'n,z' | number, |
| options?: string |
| ): Promise<ChangeInfo[] | undefined> { |
| const request = this.getRequestForGetChanges( |
| changesPerPage, |
| query, |
| offset, |
| options |
| ); |
| |
| return Promise.resolve( |
| this._restApiHelper.fetchJSON(request, true) as Promise< |
| ChangeInfo[] | undefined |
| > |
| ).then(response => { |
| if (!response) { |
| return; |
| } |
| const iterateOverChanges = (arr: ChangeInfo[]) => { |
| for (const change of arr) { |
| this._maybeInsertInLookup(change); |
| } |
| }; |
| iterateOverChanges(response); |
| return response; |
| }); |
| } |
| |
| async getDetailedChangesWithActions(changeNums: NumericChangeId[]) { |
| const query = changeNums.map(num => `change:${num}`).join(' OR '); |
| const changeDetails = await this.getChanges( |
| undefined, |
| query, |
| undefined, |
| listChangesOptionsToHex( |
| ListChangesOption.CHANGE_ACTIONS, |
| ListChangesOption.CURRENT_ACTIONS, |
| ListChangesOption.CURRENT_REVISION, |
| ListChangesOption.DETAILED_LABELS, |
| // TODO: remove this option and merge requirements from dashbaord req |
| ListChangesOption.SUBMIT_REQUIREMENTS |
| ) |
| ); |
| return changeDetails; |
| } |
| |
| /** |
| * 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 | undefined> { |
| if (!changeNum) return Promise.resolve(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() { |
| if ( |
| window.DEFAULT_DETAIL_HEXES && |
| window.DEFAULT_DETAIL_HEXES.dashboardPage |
| ) { |
| return window.DEFAULT_DETAIL_HEXES.dashboardPage; |
| } |
| const options = [ |
| ListChangesOption.LABELS, |
| ListChangesOption.DETAILED_ACCOUNTS, |
| ListChangesOption.SUBMIT_REQUIREMENTS, |
| ]; |
| |
| 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, |
| ListChangesOption.SUBMIT_REQUIREMENTS, |
| ]; |
| if (config?.receive?.enable_signed_push) { |
| options.push(ListChangesOption.PUSH_CERTIFICATES); |
| } |
| return listChangesOptionsToHex(...options); |
| } |
| |
| /** |
| * @param optionsHex list changes options in hex |
| */ |
| _getChangeDetail( |
| changeNum: NumericChangeId, |
| optionsHex: string, |
| errFn?: ErrorCallback, |
| cancelCondition?: CancelConditionCallback |
| ): Promise<ChangeInfo | undefined> { |
| 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 parsePrefixedJSON( |
| // urlWithParams already cached |
| this._etags.getCachedPayload(urlWithParams)! |
| ) as unknown as ChangeInfo; |
| } |
| |
| if (response && !response.ok) { |
| if (errFn) { |
| errFn.call(null, response); |
| } else { |
| fireServerError(response, req); |
| } |
| return undefined; |
| } |
| |
| if (!response) { |
| return Promise.resolve(undefined); |
| } |
| |
| return readResponsePayload(response).then(payload => { |
| if (!payload) { |
| return undefined; |
| } |
| 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, |
| errFn: suppress404s, |
| }) 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 (patchRange.basePatchNum !== PARENT) { |
| 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 !== PARENT) { |
| 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 (patchRange.patchNum === EDIT) { |
| 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) { |
| return this._getChangeSuggestedGroup( |
| ReviewerState.REVIEWER, |
| changeNum, |
| inputVal |
| ); |
| } |
| |
| getChangeSuggestedCCs(changeNum: NumericChangeId, inputVal: string) { |
| return this._getChangeSuggestedGroup(ReviewerState.CC, changeNum, inputVal); |
| } |
| |
| _getChangeSuggestedGroup( |
| reviewerState: ReviewerState, |
| changeNum: NumericChangeId, |
| inputVal: string |
| ): Promise<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', |
| 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, |
| project?: RepoName, |
| n?: number |
| ): Promise<GroupNameToGroupInfoMap | undefined> { |
| const params: QueryGroupsParams = {s: inputVal}; |
| if (n) { |
| params.n = n; |
| } |
| if (project) { |
| params.p = project; |
| } |
| return this._restApiHelper.fetchJSON({ |
| url: '/groups/', |
| params, |
| reportUrlAsIs: true, |
| }) as Promise<GroupNameToGroupInfoMap | undefined>; |
| } |
| |
| getSuggestedProjects( |
| inputVal: string, |
| n?: number |
| ): Promise<NameToProjectInfoMap | undefined> { |
| const params = { |
| m: inputVal, |
| n: MAX_PROJECT_RESULTS, |
| type: 'ALL', |
| }; |
| if (n) { |
| params.n = n; |
| } |
| return this._restApiHelper.fetchJSON({ |
| url: '/projects/', |
| params, |
| reportUrlAsIs: true, |
| }); |
| } |
| |
| getSuggestedAccounts( |
| inputVal: string, |
| n?: number |
| ): Promise<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/', |
| 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, |
| options: string[] = ['NON_VISIBLE_CHANGES'] |
| ): Promise<SubmittedTogetherInfo | undefined> { |
| return this._getChangeURLAndFetch({ |
| changeNum, |
| endpoint: `/submitted_together?o=${options.join('&o=')}`, |
| reportEndpointAsIs: true, |
| }) as Promise<SubmittedTogetherInfo | undefined>; |
| } |
| |
| async getChangeConflicts( |
| changeNum: NumericChangeId |
| ): Promise<ChangeInfo[] | undefined> { |
| const config = await this.getConfig(false); |
| if (!config?.change?.conflicts_predicate_enabled) { |
| return []; |
| } |
| 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, |
| options?: { |
| openChangesOnly?: boolean; |
| changeToExclude?: NumericChangeId; |
| } |
| ): Promise<ChangeInfo[] | undefined> { |
| const requestOptions = listChangesOptionsToHex( |
| ListChangesOption.LABELS, |
| ListChangesOption.CURRENT_REVISION, |
| ListChangesOption.CURRENT_COMMIT, |
| ListChangesOption.DETAILED_LABELS |
| ); |
| const queryTerms = [`topic:"${topic}"`]; |
| if (options?.openChangesOnly) { |
| queryTerms.push('status:open'); |
| } |
| if (options?.changeToExclude !== undefined) { |
| queryTerms.push(`-change:${options.changeToExclude}`); |
| } |
| const params = { |
| O: requestOptions, |
| q: queryTerms.join(' '), |
| }; |
| return this._restApiHelper.fetchJSON({ |
| url: '/changes/', |
| params, |
| anonymizedUrl: '/changes/topic:*', |
| }) as Promise<ChangeInfo[] | undefined>; |
| } |
| |
| getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined> { |
| const query = `intopic:"${topic}"`; |
| return this._restApiHelper.fetchJSON({ |
| url: '/changes/', |
| params: {q: query}, |
| anonymizedUrl: '/changes/intopic:*', |
| }) as Promise<ChangeInfo[] | undefined>; |
| } |
| |
| getChangesWithSimilarHashtag( |
| hashtag: string |
| ): Promise<ChangeInfo[] | undefined> { |
| const query = `inhashtag:"${hashtag}"`; |
| return this._restApiHelper.fetchJSON({ |
| url: '/changes/', |
| params: {q: query}, |
| anonymizedUrl: '/changes/inhashtag:*', |
| }) 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> { |
| return this._getChangeURLAndSend({ |
| changeNum, |
| method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE, |
| patchNum, |
| endpoint: `/files/${encodeURIComponent(path)}/reviewed`, |
| anonymizedEndpoint: '/files/*/reviewed', |
| }); |
| } |
| |
| saveChangeReview( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| review: ReviewInput |
| ): Promise<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): Promise<EditInfo | undefined> { |
| if (!changeNum) return Promise.resolve(undefined); |
| const params = {'download-commands': true}; |
| return this.getLoggedIn().then(loggedIn => { |
| if (!loggedIn) { |
| return Promise.resolve(undefined); |
| } |
| return this._getChangeURLAndFetch( |
| { |
| changeNum, |
| endpoint: '/edit/', |
| params, |
| reportEndpointAsIs: true, |
| }, |
| true |
| ) as Promise<EditInfo | 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 promise = |
| patchNum === EDIT |
| ? 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/*', |
| }); |
| }); |
| } |
| |
| 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 = { |
| intraline: null, |
| whitespace: whitespace || 'IGNORE_NONE', |
| }; |
| if (isMergeParent(basePatchNum)) { |
| params.parent = getParentIndex(basePatchNum); |
| } else if (basePatchNum !== PARENT) { |
| 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 === EDIT) { |
| 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: BasePatchSetNum, |
| patchNum: PatchSetNum, |
| path: string |
| ): Promise<GetDiffCommentsOutput>; |
| |
| getDiffComments( |
| changeNum: NumericChangeId, |
| basePatchNum?: BasePatchSetNum, |
| patchNum?: PatchSetNum, |
| path?: string |
| ) { |
| if (!basePatchNum && !patchNum && !path) { |
| return this._getDiffComments(changeNum, '/comments', { |
| 'enable-context': true, |
| 'context-padding': 3, |
| }); |
| } |
| return this._getDiffComments( |
| changeNum, |
| '/comments', |
| {'enable-context': true, 'context-padding': 3}, |
| basePatchNum, |
| patchNum, |
| path |
| ); |
| } |
| |
| getDiffRobotComments( |
| changeNum: NumericChangeId |
| ): Promise<PathToRobotCommentsInfoMap | undefined>; |
| |
| getDiffRobotComments( |
| changeNum: NumericChangeId, |
| basePatchNum: BasePatchSetNum, |
| patchNum: PatchSetNum, |
| path: string |
| ): Promise<GetDiffRobotCommentsOutput>; |
| |
| getDiffRobotComments( |
| changeNum: NumericChangeId, |
| basePatchNum?: BasePatchSetNum, |
| patchNum?: PatchSetNum, |
| path?: string |
| ) { |
| if (!basePatchNum && !patchNum && !path) { |
| return this._getDiffComments(changeNum, '/robotcomments'); |
| } |
| |
| return this._getDiffComments( |
| changeNum, |
| '/robotcomments', |
| undefined, |
| 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. |
| */ |
| async getDiffDrafts( |
| changeNum: NumericChangeId |
| ): Promise<{[path: string]: DraftInfo[]} | undefined> { |
| const loggedIn = await this.getLoggedIn(); |
| if (!loggedIn) return {}; |
| const comments = await this._getDiffComments(changeNum, '/drafts', { |
| 'enable-context': true, |
| 'context-padding': 3, |
| }); |
| return addDraftProp(comments); |
| } |
| |
| _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', |
| params?: FetchParams |
| ): Promise<PathToCommentsInfoMap | undefined>; |
| |
| _getDiffComments( |
| changeNum: NumericChangeId, |
| endpoint: '/robotcomments' |
| ): Promise<PathToRobotCommentsInfoMap | undefined>; |
| |
| _getDiffComments( |
| changeNum: NumericChangeId, |
| endpoint: '/comments' | '/drafts', |
| params?: FetchParams, |
| basePatchNum?: BasePatchSetNum, |
| patchNum?: PatchSetNum, |
| path?: string |
| ): Promise<GetDiffCommentsOutput>; |
| |
| _getDiffComments( |
| changeNum: NumericChangeId, |
| endpoint: '/robotcomments', |
| params?: FetchParams, |
| basePatchNum?: BasePatchSetNum, |
| patchNum?: PatchSetNum, |
| path?: string |
| ): Promise<GetDiffRobotCommentsOutput>; |
| |
| _getDiffComments( |
| changeNum: NumericChangeId, |
| endpoint: string, |
| params?: FetchParams, |
| basePatchNum?: BasePatchSetNum, |
| 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, |
| params, |
| }, |
| 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 === PARENT) { |
| baseComments = comments.filter(onlyParent); |
| baseComments.forEach(setPath); |
| } |
| comments = comments.filter(withoutParent); |
| |
| comments.forEach(setPath); |
| }); |
| promises.push(fetchPromise); |
| |
| if (basePatchNum !== PARENT) { |
| fetchPromise = fetchComments(basePatchNum).then(response => { |
| baseComments = ((response && path && response[path]) || []).filter( |
| withoutParent |
| ); |
| baseComments = this._setRanges(baseComments); |
| baseComments.forEach(setPath); |
| }); |
| promises.push(fetchPromise); |
| } |
| |
| return Promise.all(promises).then(() => |
| Promise.resolve({ |
| baseComments, |
| comments, |
| }) |
| ); |
| } |
| |
| _getDiffCommentsFetchURL( |
| changeNum: NumericChangeId, |
| endpoint: string, |
| patchNum?: RevisionId |
| ) { |
| return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint); |
| } |
| |
| getPortedComments( |
| changeNum: NumericChangeId, |
| revision: RevisionId |
| ): Promise<PathToCommentsInfoMap | undefined> { |
| // maintaining a custom error function so that errors do not surface in UI |
| const errFn: ErrorCallback = (response?: Response | null) => { |
| if (response) |
| console.info(`Fetching ported comments failed, ${response.status}`); |
| }; |
| return this._getChangeURLAndFetch({ |
| changeNum, |
| endpoint: '/ported_comments/', |
| revision, |
| errFn, |
| }); |
| } |
| |
| getPortedDrafts( |
| changeNum: NumericChangeId, |
| revision: RevisionId |
| ): Promise<PathToCommentsInfoMap | undefined> { |
| // maintaining a custom error function so that errors do not surface in UI |
| const errFn: ErrorCallback = (response?: Response | null) => { |
| if (response) |
| console.info(`Fetching ported drafts failed, ${response.status}`); |
| }; |
| return this.getLoggedIn().then(loggedIn => { |
| if (!loggedIn) return {}; |
| return this._getChangeURLAndFetch({ |
| changeNum, |
| endpoint: '/ported_drafts/', |
| revision, |
| errFn, |
| }); |
| }); |
| } |
| |
| saveDiffDraft( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| draft: CommentInput |
| ) { |
| return this._sendDiffDraftRequest( |
| HttpMethod.PUT, |
| changeNum, |
| patchNum, |
| draft |
| ); |
| } |
| |
| deleteDiffDraft( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| draft: {id: UrlEncodedCommentId} |
| ) { |
| return this._sendDiffDraftRequest( |
| HttpMethod.DELETE, |
| changeNum, |
| patchNum, |
| draft |
| ); |
| } |
| |
| /** |
| * @return Whether there are pending diff draft sends. |
| */ |
| hasPendingDiffDrafts(): number { |
| const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT]; |
| return promises && promises.length; |
| } |
| |
| /** |
| * @return 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 === PARENT) { |
| // Note: we only attempt to get the image from the first parent. |
| promiseA = this.getB64FileContents( |
| changeNum, |
| patchRange.patchNum, |
| diff.meta_a.name, |
| 1 |
| ); |
| } else { |
| promiseA = this.getB64FileContents( |
| changeNum, |
| patchRange.basePatchNum, |
| diff.meta_a.name |
| ); |
| } |
| } else { |
| promiseA = Promise.resolve(null); |
| } |
| |
| if (diff.meta_b?.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] && diff.meta_a |
| ? { |
| ...results[0], |
| _expectedType: diff.meta_a.content_type, |
| _name: diff.meta_a.name, |
| } |
| : null; |
| const revisionImage: Base64ImageFile | null = |
| results[1] && diff.meta_b |
| ? { |
| ...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): 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(): Promise<TopMenuEntryInfo[] | undefined> { |
| return this._fetchSharedCacheURL({ |
| url: '/config/server/top-menus', |
| reportUrlAsIs: true, |
| }) as Promise<TopMenuEntryInfo[] | undefined>; |
| } |
| |
| 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; |
| }); |
| } |
| |
| 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]; |
| }); |
| |