| /** |
| * @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 {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 {getParentIndex, isMergeParent} from '../../utils/patch-set-util'; |
| import {listChangesOptionsToHex} from '../../utils/change-util'; |
| import {assertNever, hasOwnProperty} from '../../utils/common-util'; |
| import {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, |
| PathToRobotCommentsInfoMap, |
| PluginInfo, |
| PreferencesInfo, |
| PreferencesInput, |
| ProjectAccessInfo, |
| RepoAccessInfoMap, |
| ProjectAccessInput, |
| ProjectInfo, |
| ProjectInfoWithName, |
| ProjectInput, |
| ProjectWatchInfo, |
| RelatedChangesInfo, |
| RepoName, |
| RequestPayload, |
| ReviewInput, |
| RevisionId, |
| ServerInfo, |
| SshKeyInfo, |
| SubmittedTogetherInfo, |
| SuggestedReviewerInfo, |
| TagInfo, |
| TagInput, |
| TopMenuEntryInfo, |
| UrlEncodedCommentId, |
| DraftInfo, |
| ListChangesOption, |
| ReviewResult, |
| } from '../../types/common'; |
| import { |
| DiffInfo, |
| DiffPreferencesInfo, |
| IgnoreWhitespaceType, |
| } from '../../types/diff'; |
| import { |
| 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 {Finalizable, ParsedChangeInfo} from '../../types/types'; |
| import {ErrorCallback} from '../../api/rest'; |
| import {addDraftProp} from '../../utils/comment-util'; |
| import {BaseScheduler, Scheduler} from '../scheduler/scheduler'; |
| import {MaxInFlightScheduler} from '../scheduler/max-in-flight-scheduler'; |
| import {escapeAndWrapSearchOperatorValue} from '../../utils/string-util'; |
| import {FlagsService, KnownExperimentId} from '../flags/flags'; |
| import {RetryScheduler} from '../scheduler/retry-scheduler'; |
| import {FixReplacementInfo} from '../../api/rest-api'; |
| import { |
| FetchParams, |
| FetchPromisesCache, |
| FetchRequest, |
| getFetchOptions, |
| GrRestApiHelper, |
| parsePrefixedJSON, |
| readJSONResponsePayload, |
| SiteBasedCache, |
| throwingErrorCallback, |
| } from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper'; |
| |
| 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. |
| // TODO: consider changing this to Map() |
| 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 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; |
| q: string; |
| n?: number; |
| o?: string; |
| } |
| |
| 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; |
| } |
| |
| export function testOnlyResetGrRestApiSharedObjects(authService: AuthService) { |
| siteBasedCache = new SiteBasedCache(); |
| fetchPromisesCache = new FetchPromisesCache(); |
| pendingRequest = {}; |
| grEtagDecorator = new GrEtagDecorator(); |
| projectLookup = {}; |
| authService.clearCache(); |
| } |
| |
| function createReadScheduler() { |
| return new RetryScheduler<Response>( |
| new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 10), |
| 3 /* maxRetry */, |
| 50 /* backoffIntervalMs */ |
| ); |
| } |
| |
| function createWriteScheduler() { |
| return new RetryScheduler<Response>( |
| new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5), |
| 3 /* maxRetry */, |
| 50 /* backoffIntervalMs */ |
| ); |
| } |
| |
| function createSerializingScheduler() { |
| return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 1); |
| } |
| |
| 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, but set in tests. |
| _projectLookup = projectLookup; // Shared across instances. |
| |
| // The value is set in created, before any other actions |
| // Private, but used in tests. |
| readonly _restApiHelper: GrRestApiHelper; |
| |
| // Used to serialize requests for certain RPCs |
| readonly _serialScheduler: Scheduler<Response>; |
| |
| constructor( |
| private readonly authService: AuthService, |
| private readonly flagService: FlagsService |
| ) { |
| const readScheduler = createReadScheduler(); |
| const writeScheduler = createWriteScheduler(); |
| this._restApiHelper = new GrRestApiHelper( |
| this._cache, |
| this.authService, |
| this._sharedFetchPromises, |
| readScheduler, |
| writeScheduler |
| ); |
| this._serialScheduler = createSerializingScheduler(); |
| } |
| |
| finalize() {} |
| |
| async getResponseObject(response: Response): Promise<ParsedJSON> { |
| return (await readJSONResponsePayload(response)).parsed; |
| } |
| |
| getConfig(noCache?: boolean): Promise<ServerInfo | undefined> { |
| if (!noCache) { |
| return this._restApiHelper.fetchCacheJSON({ |
| 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> { |
| return this._restApiHelper.fetchCacheJSON({ |
| url: '/projects/' + encodeURIComponent(repo), |
| errFn, |
| anonymizedUrl: '/projects/*', |
| }) as Promise<ProjectInfo | undefined>; |
| } |
| |
| getProjectConfig( |
| repo: RepoName, |
| errFn?: ErrorCallback |
| ): Promise<ConfigInfo | undefined> { |
| return this._restApiHelper.fetchCacheJSON({ |
| url: '/projects/' + encodeURIComponent(repo) + '/config', |
| errFn, |
| anonymizedUrl: '/projects/*/config', |
| }) as Promise<ConfigInfo | undefined>; |
| } |
| |
| getRepoAccess(repo: RepoName): Promise<RepoAccessInfoMap | undefined> { |
| return this._restApiHelper.fetchCacheJSON({ |
| url: '/access/?project=' + encodeURIComponent(repo), |
| anonymizedUrl: '/access/?project=*', |
| }) as Promise<RepoAccessInfoMap | undefined>; |
| } |
| |
| getRepoDashboards( |
| repo: RepoName, |
| errFn?: ErrorCallback |
| ): Promise<DashboardInfo[] | undefined> { |
| return this._restApiHelper.fetchCacheJSON({ |
| url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`, |
| errFn, |
| anonymizedUrl: '/projects/*/dashboards?inherited', |
| }) as Promise<DashboardInfo[] | undefined>; |
| } |
| |
| saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response> { |
| const url = `/projects/${encodeURIComponent(repo)}/config`; |
| this._cache.delete(url); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: config, |
| }), |
| url, |
| anonymizedUrl: '/projects/*/config', |
| reportServerError: true, |
| }); |
| } |
| |
| runRepoGC(repo: RepoName): Promise<Response> { |
| const encodeName = encodeURIComponent(repo); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: '', |
| }), |
| url: `/projects/${encodeName}/gc`, |
| anonymizedUrl: '/projects/*/gc', |
| reportServerError: true, |
| }); |
| } |
| |
| createRepo(config: ProjectInput & {name: RepoName}): Promise<Response> { |
| const encodeName = encodeURIComponent(config.name); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: config, |
| }), |
| url: `/projects/${encodeName}`, |
| anonymizedUrl: '/projects/*', |
| reportServerError: true, |
| }); |
| } |
| |
| createGroup(config: GroupInput & {name: string}): Promise<Response> { |
| const encodeName = encodeURIComponent(config.name); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: config, |
| }), |
| url: `/groups/${encodeName}`, |
| anonymizedUrl: '/groups/*', |
| reportServerError: true, |
| }); |
| } |
| |
| 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> { |
| const encodeName = encodeURIComponent(repo); |
| const encodeRef = encodeURIComponent(ref); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.DELETE, |
| body: '', |
| }), |
| url: `/projects/${encodeName}/branches/${encodeRef}`, |
| anonymizedUrl: '/projects/*/branches/*', |
| reportServerError: true, |
| }); |
| } |
| |
| deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response> { |
| const encodeName = encodeURIComponent(repo); |
| const encodeRef = encodeURIComponent(ref); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.DELETE, |
| body: '', |
| }), |
| url: `/projects/${encodeName}/tags/${encodeRef}`, |
| anonymizedUrl: '/projects/*/tags/*', |
| reportServerError: true, |
| }); |
| } |
| |
| createRepoBranch( |
| name: RepoName, |
| branch: BranchName, |
| revision: BranchInput |
| ): Promise<Response> { |
| const encodeName = encodeURIComponent(name); |
| const encodeBranch = encodeURIComponent(branch); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: revision, |
| }), |
| url: `/projects/${encodeName}/branches/${encodeBranch}`, |
| anonymizedUrl: '/projects/*/branches/*', |
| reportServerError: true, |
| }); |
| } |
| |
| createRepoTag( |
| name: RepoName, |
| tag: string, |
| revision: TagInput |
| ): Promise<Response> { |
| const encodeName = encodeURIComponent(name); |
| const encodeTag = encodeURIComponent(tag); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: revision, |
| }), |
| url: `/projects/${encodeName}/tags/${encodeTag}`, |
| anonymizedUrl: '/projects/*/tags/*', |
| reportServerError: true, |
| }); |
| } |
| |
| 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._restApiHelper |
| .fetchCacheJSON(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.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {name}, |
| }), |
| url: `/groups/${encodeId}/name`, |
| anonymizedUrl: '/groups/*/name', |
| reportServerError: true, |
| }); |
| } |
| |
| saveGroupOwner( |
| groupId: GroupId | GroupName, |
| ownerId: string |
| ): Promise<Response> { |
| const encodeId = encodeURIComponent(groupId); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {owner: ownerId}, |
| }), |
| url: `/groups/${encodeId}/owner`, |
| anonymizedUrl: '/groups/*/owner', |
| reportServerError: true, |
| }); |
| } |
| |
| saveGroupDescription( |
| groupId: GroupId | GroupName, |
| description: string |
| ): Promise<Response> { |
| const encodeId = encodeURIComponent(groupId); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {description}, |
| }), |
| url: `/groups/${encodeId}/description`, |
| anonymizedUrl: '/groups/*/description', |
| reportServerError: true, |
| }); |
| } |
| |
| saveGroupOptions( |
| groupId: GroupId | GroupName, |
| options: GroupOptionsInput |
| ): Promise<Response> { |
| const encodeId = encodeURIComponent(groupId); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: options, |
| }), |
| url: `/groups/${encodeId}/options`, |
| anonymizedUrl: '/groups/*/options', |
| reportServerError: true, |
| }); |
| } |
| |
| getGroupAuditLog( |
| group: EncodedGroupId, |
| errFn?: ErrorCallback |
| ): Promise<GroupAuditEventInfo[] | undefined> { |
| return this._restApiHelper.fetchCacheJSON({ |
| url: `/groups/${group}/log.audit`, |
| errFn, |
| anonymizedUrl: '/groups/*/log.audit', |
| }) as Promise<GroupAuditEventInfo[] | undefined>; |
| } |
| |
| saveGroupMember( |
| groupName: GroupId | GroupName, |
| groupMember: AccountId |
| ): Promise<AccountInfo | undefined> { |
| const encodeName = encodeURIComponent(groupName); |
| const encodeMember = encodeURIComponent(`${groupMember}`); |
| return this._restApiHelper.fetchJSON({ |
| fetchOptions: { |
| method: HttpMethod.PUT, |
| }, |
| url: `/groups/${encodeName}/members/${encodeMember}`, |
| anonymizedUrl: '/groups/*/members/*', |
| }) as unknown as Promise<AccountInfo | undefined>; |
| } |
| |
| saveIncludedGroup( |
| groupName: GroupId | GroupName, |
| includedGroup: GroupId, |
| errFn?: ErrorCallback |
| ): Promise<GroupInfo | undefined> { |
| const encodeName = encodeURIComponent(groupName); |
| const encodeIncludedGroup = encodeURIComponent(includedGroup); |
| const req = { |
| fetchOptions: { |
| method: HttpMethod.PUT, |
| }, |
| url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`, |
| errFn, |
| anonymizedUrl: '/groups/*/groups/*', |
| }; |
| return this._restApiHelper.fetchJSON(req) as unknown as Promise< |
| GroupInfo | undefined |
| >; |
| } |
| |
| deleteGroupMember( |
| groupName: GroupId | GroupName, |
| groupMember: AccountId |
| ): Promise<Response> { |
| const encodeName = encodeURIComponent(groupName); |
| const encodeMember = encodeURIComponent(`${groupMember}`); |
| return this._restApiHelper.fetch({ |
| fetchOptions: { |
| method: HttpMethod.DELETE, |
| }, |
| url: `/groups/${encodeName}/members/${encodeMember}`, |
| anonymizedUrl: '/groups/*/members/*', |
| reportServerError: true, |
| }); |
| } |
| |
| deleteIncludedGroup( |
| groupName: GroupId, |
| includedGroup: GroupId | GroupName |
| ): Promise<Response> { |
| const encodeName = encodeURIComponent(groupName); |
| const encodeIncludedGroup = encodeURIComponent(includedGroup); |
| return this._restApiHelper.fetch({ |
| fetchOptions: { |
| method: HttpMethod.DELETE, |
| }, |
| url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`, |
| anonymizedUrl: '/groups/*/groups/*', |
| reportServerError: true, |
| }); |
| } |
| |
| getVersion(): Promise<string | undefined> { |
| return this._restApiHelper.fetchCacheJSON({ |
| url: '/config/server/version', |
| reportUrlAsIs: true, |
| }) as Promise<string | undefined>; |
| } |
| |
| getDiffPreferences(): Promise<DiffPreferencesInfo | undefined> { |
| return this.getLoggedIn().then(loggedIn => { |
| if (loggedIn) { |
| return this._restApiHelper.fetchCacheJSON({ |
| 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._restApiHelper.fetchCacheJSON({ |
| url: '/accounts/self/preferences.edit', |
| reportUrlAsIs: true, |
| }) as Promise<EditPreferencesInfo | undefined>; |
| } |
| return Promise.resolve(createDefaultEditPrefs()); |
| }); |
| } |
| |
| savePreferences( |
| prefs: PreferencesInput |
| ): Promise<PreferencesInfo | undefined> { |
| // Invalidate the cache. |
| this._cache.delete('/accounts/self/preferences'); |
| |
| // 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.fetchJSON({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: prefs, |
| }), |
| url: '/accounts/self/preferences', |
| reportUrlAsIs: true, |
| }) as unknown as Promise<PreferencesInfo | undefined>; |
| } |
| |
| saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response> { |
| // Invalidate the cache. |
| this._cache.delete('/accounts/self/preferences.diff'); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: prefs, |
| }), |
| url: '/accounts/self/preferences.diff', |
| reportUrlAsIs: true, |
| reportServerError: true, |
| }); |
| } |
| |
| saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response> { |
| // Invalidate the cache. |
| this._cache.delete('/accounts/self/preferences.edit'); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: prefs, |
| }), |
| url: '/accounts/self/preferences.edit', |
| reportUrlAsIs: true, |
| reportServerError: true, |
| }); |
| } |
| |
| getAccount(): Promise<AccountDetailInfo | undefined> { |
| return this._restApiHelper.fetchCacheJSON({ |
| 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._restApiHelper.fetchCacheJSON({ |
| 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>; |
| } |
| |
| deleteAccount(): Promise<Response> { |
| return this._restApiHelper.fetch({ |
| fetchOptions: { |
| method: HttpMethod.DELETE, |
| }, |
| url: '/accounts/self', |
| reportUrlAsIs: true, |
| reportServerError: true, |
| }); |
| } |
| |
| deleteAccountIdentity(id: string[]): Promise<Response> { |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: id, |
| }), |
| url: '/accounts/self/external.ids:delete', |
| reportUrlAsIs: true, |
| reportServerError: true, |
| }); |
| } |
| |
| getAccountDetails( |
| userId: AccountId | EmailAddress, |
| errFn?: ErrorCallback |
| ): Promise<AccountDetailInfo | undefined> { |
| return this._restApiHelper.fetchCacheJSON({ |
| url: `/accounts/${encodeURIComponent(userId)}/detail`, |
| anonymizedUrl: '/accounts/*/detail', |
| errFn, |
| }) as Promise<AccountDetailInfo | undefined>; |
| } |
| |
| async getAccountEmails() { |
| const isloggedIn = await this.getLoggedIn(); |
| if (isloggedIn) { |
| return this._restApiHelper.fetchCacheJSON({ |
| url: '/accounts/self/emails', |
| reportUrlAsIs: true, |
| }) as Promise<EmailInfo[] | undefined>; |
| } else return; |
| } |
| |
| getAccountEmailsFor(email: string, errFn?: ErrorCallback) { |
| return this.getLoggedIn() |
| .then(isLoggedIn => { |
| if (isLoggedIn) { |
| return this.getAccountCapabilities(); |
| } else { |
| return undefined; |
| } |
| }) |
| .then((capabilities: AccountCapabilityInfo | undefined) => { |
| if (capabilities && capabilities.viewSecondaryEmails) { |
| return this._restApiHelper.fetchCacheJSON({ |
| url: '/accounts/' + email + '/emails', |
| reportUrlAsIs: true, |
| errFn, |
| }) as Promise<EmailInfo[] | undefined>; |
| } |
| return undefined; |
| }); |
| } |
| |
| addAccountEmail(email: string): Promise<Response> { |
| return this._restApiHelper.fetch({ |
| fetchOptions: { |
| method: HttpMethod.PUT, |
| }, |
| url: '/accounts/self/emails/' + encodeURIComponent(email), |
| anonymizedUrl: '/account/self/emails/*', |
| reportServerError: true, |
| }); |
| } |
| |
| deleteAccountEmail(email: string): Promise<Response> { |
| return this._restApiHelper.fetch({ |
| fetchOptions: { |
| method: HttpMethod.DELETE, |
| }, |
| url: '/accounts/self/emails/' + encodeURIComponent(email), |
| anonymizedUrl: '/accounts/self/email/*', |
| reportServerError: true, |
| }); |
| } |
| |
| async setPreferredAccountEmail(email: string): Promise<void> { |
| await this._restApiHelper.fetch({ |
| fetchOptions: { |
| method: HttpMethod.PUT, |
| }, |
| url: `/accounts/self/emails/${encodeURIComponent(email)}/preferred`, |
| anonymizedUrl: '/accounts/self/emails/*/preferred', |
| reportServerError: true, |
| }); |
| // 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' |
| ) as unknown as EmailInfo[]; |
| if (cachedEmails) { |
| const emails = cachedEmails.map(entry => { |
| if (entry.email === email) { |
| return {email: entry.email, preferred: true}; |
| } else { |
| return {email: entry.email, preferred: false}; |
| } |
| }); |
| this._cache.set('/accounts/self/emails', emails as unknown as ParsedJSON); |
| } |
| } |
| |
| _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, |
| }); |
| } |
| } |
| |
| async setAccountName(name: string): Promise<void> { |
| const response = await this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {name}, |
| }), |
| url: '/accounts/self/name', |
| reportUrlAsIs: true, |
| reportServerError: true, |
| }); |
| if (!response.ok) { |
| return; |
| } |
| let newName = undefined; |
| // If the name was deleted server returns 204 |
| if (response.status !== 204) { |
| newName = (await readJSONResponsePayload(response)) |
| .parsed as unknown as string; |
| } |
| this._updateCachedAccount({name: newName}); |
| } |
| |
| async setAccountUsername(username: string): Promise<void> { |
| const response = await this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {username}, |
| }), |
| url: '/accounts/self/username', |
| reportUrlAsIs: true, |
| }); |
| if (!response.ok) { |
| return; |
| } |
| let newName = undefined; |
| // If the name was deleted server returns 204 |
| if (response.status !== 204) { |
| newName = (await readJSONResponsePayload(response)) |
| .parsed as unknown as string; |
| } |
| this._updateCachedAccount({username: newName}); |
| } |
| |
| async setAccountDisplayName(displayName: string): Promise<void> { |
| const response = await this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {display_name: displayName}, |
| }), |
| url: '/accounts/self/displayname', |
| reportUrlAsIs: true, |
| reportServerError: true, |
| }); |
| if (!response.ok) { |
| return; |
| } |
| let newName = undefined; |
| // If the name was deleted server returns 204 |
| if (response.status !== 204) { |
| newName = (await readJSONResponsePayload(response)) |
| .parsed as unknown as string; |
| } |
| this._updateCachedAccount({display_name: newName}); |
| } |
| |
| async setAccountStatus(status: string): Promise<void> { |
| const response = await this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {status}, |
| }), |
| url: '/accounts/self/status', |
| reportUrlAsIs: true, |
| }); |
| if (!response.ok) { |
| return; |
| } |
| let newStatus = undefined; |
| // If the status was deleted server returns 204 |
| if (response.status !== 204) { |
| newStatus = (await readJSONResponsePayload(response)) |
| .parsed as unknown as string; |
| } |
| this._updateCachedAccount({status: newStatus}); |
| } |
| |
| getAccountStatus(userId: AccountId) { |
| return this._restApiHelper.fetchJSON({ |
| url: `/accounts/${encodeURIComponent(userId)}/status`, |
| anonymizedUrl: '/accounts/*/status', |
| }) as Promise<string | undefined>; |
| } |
| |
| 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.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: name, |
| }), |
| url: '/accounts/self/agreements', |
| reportUrlAsIs: true, |
| reportServerError: true, |
| }); |
| } |
| |
| getAccountCapabilities( |
| params?: string[] |
| ): Promise<AccountCapabilityInfo | undefined> { |
| let queryString = ''; |
| if (params) { |
| queryString = |
| '?q=' + params.map(param => encodeURIComponent(param)).join('&q='); |
| } |
| return this._restApiHelper.fetchCacheJSON({ |
| 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._restApiHelper.fetchCacheJSON({ |
| 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._restApiHelper.fetchCacheJSON(req).then(res => { |
| if (!res) { |
| return res; |
| } |
| const prefInfo = res as unknown as PreferencesInfo; |
| return prefInfo; |
| }); |
| } |
| return createDefaultPreferences(); |
| }); |
| } |
| |
| getWatchedProjects() { |
| return this._restApiHelper.fetchCacheJSON({ |
| url: '/accounts/self/watched.projects', |
| reportUrlAsIs: true, |
| }) as unknown as Promise<ProjectWatchInfo[] | undefined>; |
| } |
| |
| saveWatchedProjects( |
| projects: ProjectWatchInfo[] |
| ): Promise<ProjectWatchInfo[] | undefined> { |
| return this._restApiHelper.fetchJSON({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: projects, |
| }), |
| url: '/accounts/self/watched.projects', |
| reportUrlAsIs: true, |
| }) as unknown as Promise<ProjectWatchInfo[] | undefined>; |
| } |
| |
| deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response> { |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: projects, |
| }), |
| url: '/accounts/self/watched.projects:delete', |
| reportUrlAsIs: true, |
| reportServerError: true, |
| }); |
| } |
| |
| /** |
| * Construct the uri to get list of changes. |
| * |
| * If options is undefined then default options (see getListChangesOptionsHex) is |
| * used. |
| */ |
| getRequestForGetChanges( |
| changesPerPage?: number, |
| query?: string[] | string, |
| offset?: 'n,z' | number, |
| options?: string |
| ) { |
| options = options || this.getListChangesOptionsHex(); |
| 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: {...params, 'allow-incomplete-results': true}, |
| reportUrlAsIs: true, |
| }; |
| return request; |
| } |
| |
| /** |
| * For every query fetches the matching changes. |
| * |
| * If options is undefined then default options (see getListChangesOptionsHex) is |
| * used. |
| */ |
| 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; |
| }); |
| } |
| |
| /** |
| * Fetches changes that match the query. |
| * |
| * If options is undefined then default options (see getListChangesOptionsHex) is |
| * used. |
| */ |
| getChanges( |
| changesPerPage?: number, |
| query?: string, |
| offset?: 'n,z' | number, |
| options?: string, |
| errFn?: ErrorCallback |
| ): Promise<ChangeInfo[] | undefined> { |
| const request = this.getRequestForGetChanges( |
| changesPerPage, |
| query, |
| offset, |
| options |
| ); |
| |
| return Promise.resolve( |
| this._restApiHelper.fetchJSON( |
| { |
| ...request, |
| errFn, |
| }, |
| 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_REVISION, |
| ListChangesOption.DETAILED_LABELS, |
| // TODO: remove this option and merge requirements from dashboard 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.addRepoNameToCache(change._number, change.project); |
| } |
| } |
| |
| getChangeActionURL( |
| changeNum: NumericChangeId, |
| revisionId: RevisionId | undefined, |
| endpoint: string |
| ): Promise<string> { |
| return this._changeBaseURL(changeNum, revisionId).then( |
| url => url + endpoint |
| ); |
| } |
| |
| async getChangeDetail( |
| changeNum?: NumericChangeId, |
| errFn?: ErrorCallback |
| ): Promise<ParsedChangeInfo | undefined> { |
| if (!changeNum) return; |
| const optionsHex = await this.getChangeOptionsHex(); |
| |
| return this._getChangeDetail(changeNum, optionsHex, errFn).then(detail => |
| // detail has ChangeViewChangeInfo type because the optionsHex always |
| // includes ALL_REVISIONS flag. |
| GrReviewerUpdatesParser.parse(detail as ChangeViewChangeInfo) |
| ); |
| } |
| |
| private getListChangesOptionsHex() { |
| 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, |
| ListChangesOption.STAR, |
| ]; |
| |
| return listChangesOptionsToHex(...options); |
| } |
| |
| async getChangeOptionsHex(): Promise<string> { |
| if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.changePage) { |
| return window.DEFAULT_DETAIL_HEXES.changePage; |
| } |
| return listChangesOptionsToHex(...(await this.getChangeOptions())); |
| } |
| |
| async getChangeOptions(): Promise<number[]> { |
| const config = await this.getConfig(false); |
| |
| // This list MUST be kept in sync with |
| // ChangeIT#changeDetailsDoesNotRequireIndex and IndexPreloadingUtil#CHANGE_DETAIL_OPTIONS |
| const options = [ |
| ListChangesOption.ALL_COMMITS, |
| ListChangesOption.ALL_REVISIONS, |
| ListChangesOption.CHANGE_ACTIONS, |
| ListChangesOption.DETAILED_ACCOUNTS, |
| ListChangesOption.DETAILED_LABELS, |
| ListChangesOption.DOWNLOAD_COMMANDS, |
| ListChangesOption.MESSAGES, |
| ListChangesOption.REVIEWER_UPDATES, |
| ListChangesOption.SUBMITTABLE, |
| ListChangesOption.WEB_LINKS, |
| ListChangesOption.SKIP_DIFFSTAT, |
| ListChangesOption.SUBMIT_REQUIREMENTS, |
| ]; |
| if (this.flagService.isEnabled(KnownExperimentId.REVISION_PARENTS_DATA)) { |
| options.push(ListChangesOption.PARENTS); |
| } |
| if (config?.receive?.enable_signed_push) { |
| options.push(ListChangesOption.PUSH_CERTIFICATES); |
| } |
| return options; |
| } |
| |
| /** |
| * @param optionsHex list changes options in hex |
| */ |
| _getChangeDetail( |
| changeNum: NumericChangeId, |
| optionsHex: string, |
| errFn?: ErrorCallback |
| ): 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: FetchRequest = { |
| url, |
| errFn, |
| params, |
| fetchOptions: this._etags.getOptions(urlWithParams), |
| anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex, |
| }; |
| return this._restApiHelper.fetch(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 readJSONResponsePayload(response) |
| .then(payload => { |
| this._etags.collect(urlWithParams, response, payload.raw); |
| this._maybeInsertInLookup( |
| payload.parsed as unknown as ChangeInfo |
| ); |
| |
| return payload.parsed as unknown as ChangeInfo; |
| }) |
| .catch(() => undefined); |
| }); |
| } |
| ); |
| } |
| |
| async getChangeCommitInfo( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum |
| ): Promise<CommitInfo | undefined> { |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| return this._restApiHelper.fetchJSON({ |
| url: `${url}/commit?links`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/commit?links`, |
| errFn: suppress404s, |
| }) as Promise<CommitInfo | undefined>; |
| } |
| |
| async 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}; |
| } |
| const url = await this._changeBaseURL(changeNum, patchRange.patchNum); |
| return this._restApiHelper.fetchJSON({ |
| url: `${url}/files`, |
| params, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/files`, |
| }) as Promise<FileNameToFileInfoMap | undefined>; |
| } |
| |
| async getChangeEditFiles( |
| changeNum: NumericChangeId, |
| patchRange: PatchRange |
| ): Promise<{files: FileNameToFileInfoMap} | undefined> { |
| const changeUrl = await this._changeBaseURL(changeNum); |
| let url = `${changeUrl}/edit?list`; |
| let anonymizedUrl = `${changeUrl}/edit?list`; |
| if (patchRange.basePatchNum !== PARENT) { |
| url += '&base=' + encodeURIComponent(`${patchRange.basePatchNum}`); |
| anonymizedUrl += '&base=*'; |
| } |
| |
| const response = await this._restApiHelper.fetch({url, anonymizedUrl}); |
| if (!response.ok || response.status === 204) { |
| return undefined; |
| } |
| return (await readJSONResponsePayload(response)).parsed as unknown as |
| | {files: FileNameToFileInfoMap} |
| | undefined; |
| } |
| |
| async queryChangeFiles( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| query: string, |
| errFn?: ErrorCallback |
| ): Promise<string[] | undefined> { |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| return this._restApiHelper.fetchJSON({ |
| url: `${url}/files?q=${encodeURIComponent(query)}`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/files?q=*`, |
| errFn, |
| }) 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); |
| } |
| |
| async getChangeRevisionActions( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum |
| ): Promise<ActionNameToActionInfoMap | undefined> { |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| return this._restApiHelper.fetchJSON({ |
| url: `${url}/actions`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/actions`, |
| }) 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 |
| ); |
| } |
| |
| async _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; |
| } |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetchJSON({ |
| url: `${url}/suggest_reviewers`, |
| params, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/suggest_reviewers`, |
| errFn, |
| }) as Promise<SuggestedReviewerInfo[] | undefined>; |
| } |
| |
| async getChangeIncludedIn( |
| changeNum: NumericChangeId |
| ): Promise<IncludedInInfo | undefined> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetchJSON({ |
| url: `${url}/in`, |
| anonymizedUrl: `${url}/in`, |
| }) 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 |
| ): [boolean, string] { |
| const defaultFilter = ''; |
| offset = offset || 0; |
| filter ??= defaultFilter; |
| const encodedFilter = encodeURIComponent(filter); |
| |
| if (filter.includes(':')) { |
| // 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. |
| return [ |
| true, |
| `/projects/?n=${reposPerPage + 1}&S=${offset}` + |
| `&query=${encodedFilter}`, |
| ]; |
| } |
| |
| return [ |
| false, |
| `/projects/?n=${reposPerPage + 1}&S=${offset}` + `&d=&m=${encodedFilter}`, |
| ]; |
| } |
| |
| invalidateGroupsCache() { |
| this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?'); |
| } |
| |
| invalidateReposCache() { |
| this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?'); |
| } |
| |
| invalidateAccountsCache() { |
| this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/'); |
| } |
| |
| invalidateAccountsDetailCache() { |
| this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/detail'); |
| } |
| |
| invalidateAccountsEmailCache() { |
| this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/emails'); |
| } |
| |
| getGroups(filter: string, groupsPerPage: number, offset?: number) { |
| const url = this._getGroupsUrl(filter, groupsPerPage, offset); |
| |
| return this._restApiHelper.fetchCacheJSON({ |
| url, |
| anonymizedUrl: '/groups/?*', |
| }) as Promise<GroupNameToGroupInfoMap | undefined>; |
| } |
| |
| async getRepos( |
| filter: string | undefined, |
| reposPerPage: number, |
| offset?: number, |
| errFn?: ErrorCallback |
| ): Promise<ProjectInfoWithName[] | undefined> { |
| const [isQuery, url] = this._getReposUrl(filter, reposPerPage, offset); |
| // If the request is a query then return the response directly as the result |
| // will already be the expected array. If it is not a query, transform the |
| // map to an array. |
| if (isQuery) { |
| return this._restApiHelper.fetchCacheJSON({ |
| url, |
| anonymizedUrl: '/projects/?*', |
| errFn, |
| }) as Promise<ProjectInfoWithName[] | undefined>; |
| } else { |
| const result = await (this._restApiHelper.fetchCacheJSON({ |
| url, |
| anonymizedUrl: '/projects/?*', |
| errFn, |
| }) as Promise<NameToProjectInfoMap | undefined>); |
| if (result === undefined) return []; |
| return Object.entries(result).map(([name, project]) => { |
| return { |
| ...project, |
| name: name as RepoName, |
| }; |
| }); |
| } |
| } |
| |
| setRepoHead(repo: RepoName, ref: GitRef) { |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {ref}, |
| }), |
| url: `/projects/${encodeURIComponent(repo)}/HEAD`, |
| anonymizedUrl: '/projects/*/HEAD', |
| reportServerError: true, |
| }); |
| } |
| |
| 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}`; |
| 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; |
| 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> { |
| return this._restApiHelper.fetchJSON({ |
| url: `/projects/${encodeURIComponent(repoName)}/access`, |
| errFn, |
| anonymizedUrl: '/projects/*/access', |
| }) as Promise<ProjectAccessInfo | undefined>; |
| } |
| |
| setRepoAccessRights( |
| repoName: RepoName, |
| repoInfo: ProjectAccessInput |
| ): Promise<Response> { |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: repoInfo, |
| }), |
| url: `/projects/${encodeURIComponent(repoName)}/access`, |
| anonymizedUrl: '/projects/*/access', |
| reportServerError: true, |
| }); |
| } |
| |
| setRepoAccessRightsForReview( |
| projectName: RepoName, |
| projectInfo: ProjectAccessInput |
| ): Promise<ChangeInfo | undefined> { |
| return this._restApiHelper.fetchJSON({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: projectInfo, |
| }), |
| url: `/projects/${encodeURIComponent(projectName)}/access:review`, |
| anonymizedUrl: '/projects/*/access:review', |
| }) as unknown as Promise<ChangeInfo | undefined>; |
| } |
| |
| getSuggestedGroups( |
| inputVal: string, |
| project?: RepoName, |
| n?: number, |
| errFn?: ErrorCallback |
| ): 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, |
| errFn, |
| }) as Promise<GroupNameToGroupInfoMap | undefined>; |
| } |
| |
| getSuggestedRepos( |
| 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/', |
| params, |
| reportUrlAsIs: true, |
| errFn, |
| }); |
| } |
| |
| async queryAccounts( |
| inputVal: string, |
| n?: number, |
| canSee?: NumericChangeId, |
| filterActive?: boolean, |
| errFn?: ErrorCallback |
| ): Promise<AccountInfo[] | undefined> { |
| const params: QueryAccountsParams = {o: 'DETAILS', q: ''}; |
| const queryParams = []; |
| inputVal = inputVal?.trim() ?? ''; |
| if (inputVal.length > 0) { |
| // Wrap in quotes so that reserved keywords do not throw an error such |
| // as typing "and" |
| // Espace quotes in user input since we are wrapping input in quotes |
| // explicitly |
| queryParams.push(`${escapeAndWrapSearchOperatorValue(inputVal)}`); |
| } |
| if (canSee) { |
| const project = await this.getRepoName(canSee); |
| queryParams.push(`cansee:${project}~${canSee}`); |
| } |
| if (filterActive) { |
| queryParams.push('is:active'); |
| } |
| params.q = queryParams.join(' and '); |
| if (!params.q) return Promise.resolve([]); |
| if (n) { |
| params.n = n; |
| } |
| return this._restApiHelper.fetchJSON({ |
| url: '/accounts/', |
| params, |
| anonymizedUrl: '/accounts/?n=*', |
| errFn, |
| }) as Promise<AccountInfo[] | undefined>; |
| } |
| |
| getAccountSuggestions(inputVal: string): Promise<AccountInfo[] | undefined> { |
| const params: QueryAccountsParams = {suggest: undefined, q: ''}; |
| inputVal = inputVal?.trim() ?? ''; |
| if (inputVal.length > 0) { |
| params.q = inputVal; |
| } |
| if (!params.q) return Promise.resolve([]); |
| return this._restApiHelper.fetchJSON({ |
| url: '/accounts/', |
| params, |
| }) 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.fetch({ |
| fetchOptions: getFetchOptions({method, body}), |
| url, |
| reportServerError: true, |
| }); |
| } |
| ); |
| } |
| |
| async getRelatedChanges( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum |
| ): Promise<RelatedChangesInfo | undefined> { |
| const options = '?o=SUBMITTABLE'; |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| return this._restApiHelper.fetchJSON({ |
| url: `${url}/related${options}`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/related${options}`, |
| }) as Promise<RelatedChangesInfo | undefined>; |
| } |
| |
| async getChangesSubmittedTogether( |
| changeNum: NumericChangeId, |
| options: string[] = ['NON_VISIBLE_CHANGES'] |
| ): Promise<SubmittedTogetherInfo | undefined> { |
| const url = await this._changeBaseURL(changeNum); |
| const endpoint = `/submitted_together?o=${options.join('&o=')}`; |
| return this._restApiHelper.fetchJSON({ |
| url: `${url}${endpoint}`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}${endpoint}`, |
| }) 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, |
| ListChangesOption.SUBMITTABLE |
| ); |
| const params = { |
| O: options, |
| q: `status:open conflicts:${changeNum}`, |
| }; |
| return this._restApiHelper.fetchJSON({ |
| url: '/changes/', |
| params, |
| anonymizedUrl: '/changes/conflicts:*', |
| }) as Promise<ChangeInfo[] | undefined>; |
| } |
| |
| getChangeCherryPicks( |
| repo: RepoName, |
| changeID: ChangeId, |
| branch: BranchName |
| ): Promise<ChangeInfo[] | undefined> { |
| const options = listChangesOptionsToHex( |
| ListChangesOption.CURRENT_REVISION, |
| ListChangesOption.CURRENT_COMMIT |
| ); |
| const query = [ |
| `project:${repo}`, |
| `change:${changeID}`, |
| `-branch:${branch}`, |
| '-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, |
| ListChangesOption.SUBMITTABLE |
| ); |
| const queryTerms = [`topic:${escapeAndWrapSearchOperatorValue(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, |
| errFn?: ErrorCallback |
| ): Promise<ChangeInfo[] | undefined> { |
| const query = `intopic:${escapeAndWrapSearchOperatorValue(topic)}`; |
| return this._restApiHelper.fetchJSON({ |
| url: '/changes/', |
| params: {q: query}, |
| anonymizedUrl: '/changes/intopic:*', |
| errFn, |
| }) as Promise<ChangeInfo[] | undefined>; |
| } |
| |
| getChangesWithSimilarHashtag( |
| hashtag: string, |
| errFn?: ErrorCallback |
| ): Promise<ChangeInfo[] | undefined> { |
| const query = `inhashtag:${escapeAndWrapSearchOperatorValue(hashtag)}`; |
| return this._restApiHelper.fetchJSON({ |
| url: '/changes/', |
| params: {q: query}, |
| anonymizedUrl: '/changes/inhashtag:*', |
| errFn, |
| }) as Promise<ChangeInfo[] | undefined>; |
| } |
| |
| async getReviewedFiles( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum |
| ): Promise<string[] | undefined> { |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| return this._restApiHelper.fetchJSON({ |
| url: `${url}/files?reviewed`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/files?reviewed`, |
| }) as Promise<string[] | undefined>; |
| } |
| |
| async saveFileReviewed( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| path: string, |
| reviewed: boolean |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: {method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE}, |
| url: `${url}/files/${encodeURIComponent(path)}/reviewed`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/files/*/reviewed`, |
| reportServerError: true, |
| }); |
| } |
| |
| getSaveReviewChangeOptions(): string[] { |
| const options = [ |
| 'CHANGE_ACTIONS', |
| 'DETAILED_LABELS', |
| 'DETAILED_ACCOUNTS', |
| 'MESSAGES', |
| 'REVIEWER_UPDATES', |
| 'SUBMITTABLE', |
| 'SKIP_DIFFSTAT', |
| 'SUBMIT_REQUIREMENTS', |
| ]; |
| return options; |
| } |
| |
| async saveChangeReview( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| review: ReviewInput, |
| errFn?: ErrorCallback, |
| fetchDetail?: boolean |
| ): Promise<ReviewResult | undefined> { |
| if (fetchDetail) { |
| review.response_format_options = this.getSaveReviewChangeOptions(); |
| } |
| const promises: [Promise<void>, Promise<string>] = [ |
| this.awaitPendingDiffDrafts(), |
| this.getChangeActionURL(changeNum, patchNum, '/review'), |
| ]; |
| return Promise.all(promises).then(([, url]) => |
| this._restApiHelper.fetchJSON({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: review, |
| }), |
| url, |
| errFn, |
| }) |
| ) as unknown as Promise<ReviewResult | undefined>; |
| } |
| |
| async getChangeEdit( |
| changeNum?: NumericChangeId |
| ): Promise<EditInfo | undefined> { |
| if (!changeNum) return undefined; |
| const params = {'download-commands': true}; |
| const loggedIn = await this.getLoggedIn(); |
| if (!loggedIn) { |
| return undefined; |
| } |
| const url = await this._changeBaseURL(changeNum); |
| const response = await this._restApiHelper.fetch({ |
| url: `${url}/edit/`, |
| params, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit/`, |
| }); |
| // If there is no edit patchset 204 is returned. |
| if (!response.ok || response.status === 204) { |
| return undefined; |
| } |
| return (await readJSONResponsePayload(response)) |
| .parsed as unknown as EditInfo; |
| } |
| |
| createChange( |
| repo: RepoName, |
| branch: BranchName, |
| subject: string, |
| topic?: string, |
| isPrivate?: boolean, |
| workInProgress?: boolean, |
| baseChange?: ChangeId, |
| baseCommit?: string |
| ): Promise<ChangeInfo | undefined> { |
| return this._restApiHelper.fetchJSON({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: { |
| project: repo, |
| branch, |
| subject, |
| topic, |
| is_private: isPrivate, |
| work_in_progress: workInProgress, |
| base_change: baseChange, |
| base_commit: baseCommit, |
| }, |
| }), |
| url: '/changes/', |
| 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 => { |
| // A 204 is returned if the file is empty so we have |
| // to return early. |
| if (!res.ok || res.status === 204) { |
| 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 readJSONResponsePayload(res).then(content => { |
| const strContent = content.parsed as unknown as string | null; |
| return {content: strContent, type, ok: true}; |
| }); |
| }); |
| } |
| |
| /** |
| * Gets a file in a specific change and revision. |
| */ |
| async _getFileInRevision( |
| changeNum: NumericChangeId, |
| path: string, |
| patchNum: PatchSetNum, |
| errFn?: ErrorCallback |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| headers: {Accept: 'application/json'}, |
| }), |
| url: `${url}/files/${encodeURIComponent(path)}/content`, |
| errFn, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/files/*/content`, |
| reportServerError: true, |
| }); |
| } |
| |
| /** |
| * Gets a file in a change edit. |
| */ |
| async _getFileInChangeEdit( |
| changeNum: NumericChangeId, |
| path: string |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| headers: {Accept: 'application/json'}, |
| }), |
| url: `${url}/edit/${encodeURIComponent(path)}`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit/*`, |
| reportServerError: true, |
| }); |
| } |
| |
| async rebaseChangeEdit(changeNum: NumericChangeId): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: {method: HttpMethod.POST}, |
| url: `${url}/edit:rebase`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit:rebase`, |
| reportServerError: true, |
| }); |
| } |
| |
| async deleteChangeEdit(changeNum: NumericChangeId): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: {method: HttpMethod.DELETE}, |
| url: `${url}/edit`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit`, |
| reportServerError: true, |
| }); |
| } |
| |
| async restoreFileInChangeEdit( |
| changeNum: NumericChangeId, |
| restore_path: string |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: {restore_path}, |
| }), |
| url: `${url}/edit`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit`, |
| reportServerError: true, |
| }); |
| } |
| |
| async renameFileInChangeEdit( |
| changeNum: NumericChangeId, |
| old_path: string, |
| new_path: string |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: {old_path, new_path}, |
| }), |
| url: `${url}/edit`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit`, |
| reportServerError: true, |
| }); |
| } |
| |
| async deleteFileInChangeEdit( |
| changeNum: NumericChangeId, |
| path: string |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: {method: HttpMethod.DELETE}, |
| url: `${url}/edit/${encodeURIComponent(path)}`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit/*`, |
| reportServerError: true, |
| }); |
| } |
| |
| async saveChangeEdit( |
| changeNum: NumericChangeId, |
| path: string, |
| contents: string |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: contents, |
| contentType: 'text/plain', |
| }), |
| url: `${url}/edit/${encodeURIComponent(path)}`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit/*`, |
| reportServerError: true, |
| }); |
| } |
| |
| async saveFileUploadChangeEdit( |
| changeNum: NumericChangeId, |
| path: string, |
| content: string |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {binary_content: content}, |
| }), |
| url: `${url}/edit/${encodeURIComponent(path)}`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit/*`, |
| reportServerError: true, |
| }); |
| } |
| |
| async getFixPreview( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| fixReplacementInfos: FixReplacementInfo[] |
| ): Promise<FilePathToDiffInfoMap | undefined> { |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| return this._restApiHelper.fetchJSON({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: {fix_replacement_infos: fixReplacementInfos}, |
| }), |
| url: `${url}/fix:preview`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/fix:preview`, |
| }) as Promise<FilePathToDiffInfoMap | undefined>; |
| } |
| |
| async getRobotCommentFixPreview( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| fixId: FixId |
| ): Promise<FilePathToDiffInfoMap | undefined> { |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| const endpoint = `/fixes/${encodeURIComponent(fixId)}/preview`; |
| return this._restApiHelper.fetchJSON({ |
| url: `${url}${endpoint}`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}${endpoint}`, |
| }) as Promise<FilePathToDiffInfoMap | undefined>; |
| } |
| |
| async applyFixSuggestion( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| fixReplacementInfos: FixReplacementInfo[] |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| headers: {Accept: 'application/json'}, |
| body: {fix_replacement_infos: fixReplacementInfos}, |
| }), |
| url: `${url}/fix:apply`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/fix:apply`, |
| reportServerError: true, |
| }); |
| } |
| |
| async applyRobotFixSuggestion( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| fixId: string |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| const endpoint = `/fixes/${encodeURIComponent(fixId)}/apply`; |
| return this._restApiHelper.fetch({ |
| fetchOptions: {method: HttpMethod.POST}, |
| url: `${url}${endpoint}`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}${endpoint}`, |
| reportServerError: true, |
| }); |
| } |
| |
| async publishChangeEdit(changeNum: NumericChangeId) { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: {method: HttpMethod.POST}, |
| url: `${url}/edit:publish`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit:publish`, |
| reportServerError: true, |
| }); |
| } |
| |
| async putChangeCommitMessage( |
| changeNum: NumericChangeId, |
| message: string, |
| committerEmail: string | null |
| ) { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {message, committer_email: committerEmail}, |
| }), |
| url: `${url}/message`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/message`, |
| reportServerError: true, |
| }); |
| } |
| |
| async updateIdentityInChangeEdit( |
| changeNum: NumericChangeId, |
| name: string, |
| email: string, |
| type: string |
| ) { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {name, email, type}, |
| }), |
| url: `${url}/edit:identity`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit:identity`, |
| reportServerError: true, |
| }); |
| } |
| |
| async deleteChangeCommitMessage( |
| changeNum: NumericChangeId, |
| messageId: ChangeMessageId |
| ) { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: {method: HttpMethod.DELETE}, |
| url: `${url}/messages/${messageId}`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/messages/${messageId}`, |
| reportServerError: 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.getRepoName(changeNum).then(project => { |
| const encodedRepoName = encodeURIComponent(project) + '~'; |
| const url = `/accounts/self/starred.changes/${encodedRepoName}${changeNum}`; |
| return this._serialScheduler.schedule(() => |
| this._restApiHelper.fetch({ |
| fetchOptions: {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.fetch({ |
| fetchOptions: getFetchOptions({ |
| method, |
| body, |
| contentType, |
| headers, |
| }), |
| url, |
| errFn, |
| reportServerError: true, |
| }); |
| } |
| |
| async getDiff( |
| changeNum: NumericChangeId, |
| basePatchNum: PatchSetNum, |
| patchNum: PatchSetNum, |
| path: string, |
| whitespace?: IgnoreWhitespaceType, |
| errFn?: ErrorCallback |
| ): Promise<DiffInfo | undefined> { |
| const params: GetDiffParams = { |
| intraline: null, |
| whitespace: whitespace || 'IGNORE_NONE', |
| }; |
| if (isMergeParent(basePatchNum)) { |
| params.parent = getParentIndex(basePatchNum); |
| } else if (basePatchNum !== PARENT) { |
| params.base = basePatchNum; |
| } |
| |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| return this._restApiHelper.fetchJSON({ |
| // Invalidate the cache if this is the edit patch to make sure we always |
| // get latest. |
| fetchOptions: getFetchOptions({ |
| headers: patchNum === EDIT ? {'Cache-Control': 'no-cache'} : undefined, |
| }), |
| url: `${url}/files/${encodeURIComponent(path)}/diff`, |
| params, |
| errFn, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}'/files/*/diff`, |
| }) as Promise<DiffInfo | undefined>; |
| } |
| |
| getDiffComments( |
| changeNum: NumericChangeId |
| ): Promise<{[path: string]: CommentInfo[]} | 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 |
| ); |
| } |
| |
| 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<{[path: string]: CommentInfo[]} | 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>; |
| |
| /** |
| * Fetches the comments for a given patchNum. |
| * Helper function to make promises more legible. |
| */ |
| _getDiffComments( |
| changeNum: NumericChangeId, |
| endpoint: string, |
| params?: FetchParams, |
| basePatchNum?: BasePatchSetNum, |
| patchNum?: PatchSetNum, |
| path?: string |
| ): Promise< |
| | GetDiffCommentsOutput |
| | GetDiffRobotCommentsOutput |
| | {[path: string]: CommentInfo[]} |
| | PathToRobotCommentsInfoMap |
| | undefined |
| > { |
| // 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._changeBaseURL(changeNum, patchNum).then(url => |
| this._restApiHelper.fetchJSON( |
| { |
| url: `${url}${endpoint}`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}${endpoint}`, |
| params, |
| }, |
| noAcceptHeader |
| ) |
| ) as Promise< |
| {[path: string]: CommentInfo[]} | 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]) || []; |
| // 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, |
| }) |
| ); |
| } |
| |
| async getPortedComments( |
| changeNum: NumericChangeId, |
| revision: RevisionId |
| ): Promise<{[path: string]: CommentInfo[]} | 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}`); |
| }; |
| const url = await this._changeBaseURL(changeNum, revision); |
| return this._restApiHelper.fetchJSON({ |
| url: `${url}/ported_comments/`, |
| errFn, |
| }); |
| } |
| |
| async getPortedDrafts( |
| changeNum: NumericChangeId, |
| revision: RevisionId |
| ): Promise<{[path: string]: DraftInfo[]} | 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}`); |
| }; |
| const loggedIn = await this.getLoggedIn(); |
| if (!loggedIn) return {}; |
| const url = await this._changeBaseURL(changeNum, revision); |
| const comments = (await this._restApiHelper.fetchJSON({ |
| url: `${url}/ported_drafts/`, |
| errFn, |
| })) as {[path: string]: CommentInfo[]} | undefined; |
| return addDraftProp(comments); |
| } |
| |
| 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 |
| ); |
| } |
| |
| hasPendingDiffDrafts(): number { |
| const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT]; |
| return promises && promises.length; |
| } |
| |
| 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 += '/*'; |
| } |
| |
| if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) { |
| this._pendingRequests[Requests.SEND_DIFF_DRAFT] = []; |
| } |
| |
| const fetchOptions = |
| method === HttpMethod.PUT |
| ? getFetchOptions({method, body: draft}) |
| : {method}; |
| |
| const promise = this._changeBaseURL(changeNum, patchNum).then(url => |
| this._restApiHelper.fetch({ |
| fetchOptions, |
| url: `${url}${endpoint}`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}${anonymizedEndpoint}`, |
| reportServerError: true, |
| }) |
| ); |
| |
| this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise); |
| |
| if (isCreate) { |
| return this._failForCreate200(promise); |
| } |
| |
| return promise; |
| } |
| |
| getCommitInfo( |
| repo: RepoName, |
| commit: CommitId |
| ): Promise<CommitInfo | undefined> { |
| return this._restApiHelper.fetchJSON({ |
| url: |
| '/projects/' + |
| encodeURIComponent(repo) + |
| '/commits/' + |
| encodeURIComponent(commit), |
| anonymizedUrl: '/projects/*/commits/*', |
| }) 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 |
| ): Promise<string> { |
| return this.getRepoName(changeNum).then(project => { |
| let url = `/changes/${encodeURIComponent(project)}~${changeNum}`; |
| if (revisionId) { |
| url += `/revisions/${revisionId}`; |
| } |
| return url; |
| }); |
| } |
| |
| async addToAttentionSet( |
| changeNum: NumericChangeId, |
| user: AccountId | undefined | null, |
| reason: string |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: {user, reason}, |
| }), |
| url: `${url}/attention`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/attention`, |
| reportServerError: true, |
| }); |
| } |
| |
| async removeFromAttentionSet( |
| changeNum: NumericChangeId, |
| user: AccountId, |
| reason: string |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.DELETE, |
| body: {reason}, |
| }), |
| url: `${url}/attention/${user}`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/attention/*`, |
| reportServerError: true, |
| }); |
| } |
| |
| async setChangeTopic( |
| changeNum: NumericChangeId, |
| topic?: string, |
| errFn?: ErrorCallback |
| ): Promise<string | undefined> { |
| const url = await this._changeBaseURL(changeNum); |
| const response = await this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {topic}, |
| }), |
| url: `${url}/topic`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/topic`, |
| errFn, |
| }); |
| if (!response.ok) { |
| return undefined; |
| } |
| if (response.status === 204) { |
| return ''; |
| } |
| return (await readJSONResponsePayload(response)) |
| .parsed as unknown as string; |
| } |
| |
| removeChangeTopic( |
| changeNum: NumericChangeId, |
| errFn?: ErrorCallback |
| ): Promise<string | undefined> { |
| return this.setChangeTopic(changeNum, '', errFn); |
| } |
| |
| async setChangeHashtag( |
| changeNum: NumericChangeId, |
| hashtag: HashtagsInput, |
| errFn?: ErrorCallback |
| ): Promise<Hashtag[] | undefined> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetchJSON({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: hashtag, |
| }), |
| url: `${url}/hashtags`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/hashtags`, |
| errFn, |
| }) as unknown as Promise<Hashtag[] | undefined>; |
| } |
| |
| deleteAccountHttpPassword(): Promise<Response> { |
| return this._restApiHelper.fetch({ |
| fetchOptions: {method: HttpMethod.DELETE}, |
| url: '/accounts/self/password.http', |
| reportUrlAsIs: true, |
| reportServerError: true, |
| }); |
| } |
| |
| generateAccountHttpPassword(): Promise<Password | undefined> { |
| return this._restApiHelper.fetchJSON({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {generate: true}, |
| }), |
| url: '/accounts/self/password.http', |
| reportUrlAsIs: true, |
| }) as Promise<unknown> as Promise<Password>; |
| } |
| |
| getAccountSSHKeys() { |
| return this._restApiHelper.fetchCacheJSON({ |
| url: '/accounts/self/sshkeys', |
| reportUrlAsIs: true, |
| }) as Promise<unknown> as Promise<SshKeyInfo[] | undefined>; |
| } |
| |
| addAccountSSHKey(key: string): Promise<SshKeyInfo> { |
| // By passing throwingErrorCallback we guarantee that response is not-null. |
| return this._restApiHelper.fetchJSON({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: key, |
| contentType: 'text/plain', |
| }), |
| url: '/accounts/self/sshkeys', |
| reportUrlAsIs: true, |
| errFn: throwingErrorCallback, |
| }) as Promise<unknown> as Promise<SshKeyInfo>; |
| } |
| |
| deleteAccountSSHKey(id: string): Promise<Response> { |
| return this._restApiHelper.fetch({ |
| fetchOptions: {method: HttpMethod.DELETE}, |
| url: '/accounts/self/sshkeys/' + id, |
| anonymizedUrl: '/accounts/self/sshkeys/*', |
| reportServerError: true, |
| }); |
| } |
| |
| getAccountGPGKeys() { |
| return this._restApiHelper.fetchJSON({ |
| url: '/accounts/self/gpgkeys', |
| reportUrlAsIs: true, |
| }) as Promise<unknown> as Promise<Record<string, GpgKeyInfo>>; |
| } |
| |
| addAccountGPGKey(key: GpgKeysInput): Promise<Record<string, GpgKeyInfo>> { |
| // By passing throwingErrorCallback we guarantee that response is not-null. |
| return this._restApiHelper.fetchJSON({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: key, |
| }), |
| url: '/accounts/self/gpgkeys', |
| reportUrlAsIs: true, |
| errFn: throwingErrorCallback, |
| }) as Promise<unknown> as Promise<Record<string, GpgKeyInfo>>; |
| } |
| |
| deleteAccountGPGKey(id: GpgKeyId) { |
| return this._restApiHelper.fetch({ |
| fetchOptions: {method: HttpMethod.DELETE}, |
| url: `/accounts/self/gpgkeys/${id}`, |
| anonymizedUrl: '/accounts/self/gpgkeys/*', |
| reportServerError: true, |
| }); |
| } |
| |
| async deleteVote( |
| changeNum: NumericChangeId, |
| account: AccountId, |
| label: string |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: {method: HttpMethod.DELETE}, |
| url: `${url}/reviewers/${account}/votes/${encodeURIComponent(label)}`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/reviewers/*/votes/*`, |
| reportServerError: true, |
| }); |
| } |
| |
| async setDescription( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| desc: string |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {description: desc}, |
| }), |
| url: `${url}/description`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/description`, |
| reportServerError: true, |
| }); |
| } |
| |
| async confirmEmail(token: string): Promise<string | null> { |
| const response = await this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.PUT, |
| body: {token}, |
| }), |
| url: '/config/server/email.confirm', |
| reportUrlAsIs: true, |
| reportServerError: true, |
| }); |
| 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._restApiHelper.fetchCacheJSON({ |
| 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 |
| ); |
| } |
| |
| async startWorkInProgress( |
| changeNum: NumericChangeId, |
| message?: string |
| ): Promise<string | undefined> { |
| const url = await this._changeBaseURL(changeNum); |
| const response = await this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: message ? {message} : {}, |
| }), |
| url: `${url}/wip`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/wip`, |
| reportServerError: true, |
| }); |
| if (response.status === 204) { |
| return 'Change marked as Work In Progress.'; |
| } |
| return undefined; |
| } |
| |
| async deleteComment( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| commentID: UrlEncodedCommentId, |
| reason: string |
| ): Promise<CommentInfo | undefined> { |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| return this._restApiHelper.fetchJSON({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body: {reason}, |
| }), |
| url: `${url}/comments/${commentID}/delete`, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/comments/*/delete`, |
| }) as unknown as Promise<CommentInfo | undefined>; |
| } |
| |
| getChange( |
| changeNum: ChangeId | NumericChangeId, |
| errFn: ErrorCallback |
| ): Promise<ChangeInfo | undefined> { |
| if (changeNum in this._projectLookup) { |
| // _projectLookup can only store NumericChangeId, so we are sure that |
| // changeNum is NumericChangeId in this case. |
| return this._changeBaseURL(changeNum as NumericChangeId).then(url => |
| this._restApiHelper.fetchJSON( |
| { |
| url, |
| errFn, |
| anonymizedUrl: '/changes/*~*', |
| }, |
| /* noAcceptHeader */ true |
| ) |
| ) as Promise<ChangeInfo | undefined>; |
| } else { |
| return this._restApiHelper |
| .fetchJSON( |
| { |
| url: `/changes/?q=change:${changeNum}`, |
| errFn, |
| anonymizedUrl: '/changes/?q=change:*', |
| }, |
| /* noAcceptHeader */ true |
| ) |
| .then(res => { |
| const changeInfos = res as ChangeInfo[] | undefined; |
| if (!changeInfos || !changeInfos.length) { |
| return undefined; |
| } |
| return changeInfos[0]; |
| }); |
| } |
| } |
| |
| /** |
| * This can be called by the router, if the project can be determined from |
| * the URL. Or when handling a dashabord or a search response. |
| * |
| * Then we don't need to make a dedicated REST API call or have a fallback, |
| * if that fails. |
| */ |
| addRepoNameToCache(changeNum: NumericChangeId, project: RepoName) { |
| this._projectLookup[changeNum] = Promise.resolve(project); |
| } |
| |
| getRepoName(changeNum: NumericChangeId): Promise<RepoName> { |
| // Hopefully addRepoNameToCache() has already been called. Then we don't |
| // have to make a dedicated REST API call to look up the project. |
| let projectPromise = this._projectLookup[changeNum]; |
| if (projectPromise) return projectPromise; |
| |
| // Ignore errors, because we have some dedicated fallback logic, see below. |
| const onError = () => {}; |
| projectPromise = this.getChange(changeNum, onError).then(change => { |
| if (change?.project) return change.project; |
| |
| // In the very rare case that the change index cannot provide an answer |
| // (e.g. stale index) we should check, if the router has called |
| // addRepoNameToCache() in the meantime. Then we can fall back to that. |
| const currentProjectPromise = this._projectLookup[changeNum]; |
| if (currentProjectPromise && currentProjectPromise !== projectPromise) { |
| return currentProjectPromise; |
| } |
| |
| // No luck. Without knowing the project we cannot proceed at all. |
| firePageError( |
| new Response( |
| `Failed to lookup the repo for change number ${changeNum}`, |
| {status: 404} |
| ) |
| ); |
| // Don't store failed lookups in the lookup. |
| this._projectLookup[changeNum] = undefined; |
| throw new Error( |
| `Failed to lookup the repo for change number ${changeNum}` |
| ); |
| }); |
| this._projectLookup[changeNum] = projectPromise; |
| return projectPromise; |
| } |
| |
| async executeChangeAction( |
| changeNum: NumericChangeId, |
| method: HttpMethod | undefined, |
| endpoint: string, |
| patchNum?: PatchSetNum, |
| payload?: RequestPayload, |
| errFn?: ErrorCallback |
| ): Promise<Response> { |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| // No anonymizedUrl specified so the request will not be logged. |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method, |
| body: payload, |
| }), |
| url: url + endpoint, |
| errFn, |
| reportServerError: true, |
| }); |
| } |
| |
| async getBlame( |
| changeNum: NumericChangeId, |
| patchNum: PatchSetNum, |
| path: string, |
| base?: boolean |
| ): Promise<BlameInfo[] | undefined> { |
| const encodedPath = encodeURIComponent(path); |
| const url = await this._changeBaseURL(changeNum, patchNum); |
| return this._restApiHelper.fetchJSON({ |
| url: `${url}/files/${encodedPath}/blame`, |
| params: base ? {base: 't'} : undefined, |
| anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/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; |
| }); |
| } |
| |
| getDashboard( |
| repo: RepoName, |
| dashboard: DashboardId, |
| errFn?: ErrorCallback |
| ): Promise<DashboardInfo | undefined> { |
| const url = |
| '/projects/' + |
| encodeURIComponent(repo) + |
| '/dashboards/' + |
| encodeURIComponent(dashboard); |
| return this._restApiHelper.fetchCacheJSON({ |
| url, |
| errFn, |
| anonymizedUrl: '/projects/*/dashboards/*', |
| }) as Promise<DashboardInfo | undefined>; |
| } |
| |
| getDocumentationSearches(filter: string): Promise<DocResult[] | undefined> { |
| filter = filter.trim(); |
| const encodedFilter = encodeURIComponent(filter); |
| |
| return this._restApiHelper.fetchCacheJSON({ |
| url: `/Documentation/?q=${encodedFilter}`, |
| anonymizedUrl: '/Documentation/?*', |
| }) as Promise<DocResult[] | undefined>; |
| } |
| |
| async getMergeable( |
| changeNum: NumericChangeId |
| ): Promise<MergeableInfo | undefined> { |
| const url = await this._changeBaseURL(changeNum); |
| return this._restApiHelper.fetchJSON({ |
| url: `${url}/revisions/current/mergeable`, |
| anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/revisions/current/mergeable`, |
| }) as Promise<MergeableInfo | undefined>; |
| } |
| |
| deleteDraftComments(query: string): Promise<Response> { |
| const body: DeleteDraftCommentsInput = {query}; |
| return this._restApiHelper.fetch({ |
| fetchOptions: getFetchOptions({ |
| method: HttpMethod.POST, |
| body, |
| }), |
| url: '/accounts/self/drafts:delete', |
| }); |
| } |
| } |