blob: 7a12e7a1d49a922351547fccc2a0e8b7d65ac461 [file] [log] [blame]
/**
* @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',
});
}
}