blob: 1657c2343852f331cf1294671bc746f89dc9977d [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 {
FetchJSONRequest,
FetchParams,
FetchPromisesCache,
GrRestApiHelper,
parsePrefixedJSON,
readResponsePayload,
SendJSONRequest,
SendRequest,
SiteBasedCache,
} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
import {parseDate} from '../../utils/date-util';
import {getBaseUrl} from '../../utils/url-util';
import {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,
FixReplacementInfo,
DraftInfo,
ListChangesOption,
ReviewResult,
} from '../../types/common';
import {
DiffInfo,
DiffPreferencesInfo,
IgnoreWhitespaceType,
} from '../../types/diff';
import {
CancelConditionCallback,
GetDiffCommentsOutput,
GetDiffRobotCommentsOutput,
RestApiService,
} from './gr-rest-api';
import {
CommentSide,
createDefaultDiffPrefs,
createDefaultEditPrefs,
createDefaultPreferences,
HttpMethod,
ReviewerState,
} from '../../constants/constants';
import {firePageError, fireServerError} from '../../utils/event-util';
import {
AuthRequestInit,
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';
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 FetchChangeJSON {
reportEndpointAsIs?: boolean;
endpoint: string;
anonymizedEndpoint?: string;
revision?: RevisionId;
changeNum: NumericChangeId;
errFn?: ErrorCallback;
params?: FetchParams;
fetchOptions?: AuthRequestInit;
// TODO(TS): The following properties are not used, however some methods
// set them to true. They should be either changed to reportEndpointAsIs: true
// or deleted. This should be done carefully case by case.
reportEndpointAsId?: true;
}
interface SendChangeRequestBase {
patchNum?: PatchSetNum;
reportEndpointAsIs?: boolean;
endpoint: string;
anonymizedEndpoint?: string;
changeNum: NumericChangeId;
method: HttpMethod | undefined;
errFn?: ErrorCallback;
headers?: Record<string, string>;
contentType?: string;
body?: string | object;
// TODO(TS): The following properties are not used, however some methods
// set them to true. They should be either changed to reportEndpointAsIs: true
// or deleted. This should be done carefully case by case.
reportUrlAsIs?: true;
reportEndpointAsId?: true;
}
interface SendRawChangeRequest extends SendChangeRequestBase {
parseResponse?: false | null;
}
interface SendJSONChangeRequest extends SendChangeRequestBase {
parseResponse: true;
}
interface QueryChangesParams {
[paramName: string]: string | undefined | number | string[];
O?: string; // options
S: number; // start
n?: number; // changes per page
q?: string | string[]; // query/queries
}
interface QueryAccountsParams {
[paramName: string]: string | undefined | null | number;
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;
}
type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest;
export function testOnlyResetGrRestApiSharedObjects(authService: AuthService) {
siteBasedCache = new SiteBasedCache();
fetchPromisesCache = new FetchPromisesCache();
pendingRequest = {};
grEtagDecorator = new GrEtagDecorator();
projectLookup = {};
authService.clearCache();
}
function createReadScheduler() {
return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 10);
}
function createWriteScheduler() {
return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5);
}
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
) {
this._restApiHelper = new GrRestApiHelper(
this._cache,
this.authService,
this._sharedFetchPromises,
createReadScheduler(),
createWriteScheduler()
);
this._serialScheduler = createSerializingScheduler();
}
finalize() {}
_fetchSharedCacheURL(
req: FetchJSONRequest
): Promise<AccountDetailInfo | ParsedJSON | undefined> {
// Cache is shared across instances
return this._restApiHelper.fetchCacheURL(req);
}
getResponseObject(response: Response): Promise<ParsedJSON> {
return this._restApiHelper.getResponseObject(response);
}
getConfig(noCache?: boolean): Promise<ServerInfo | undefined> {
if (!noCache) {
return this._fetchSharedCacheURL({
url: '/config/server/info',
reportUrlAsIs: true,
}) as Promise<ServerInfo | undefined>;
}
return this._restApiHelper.fetchJSON({
url: '/config/server/info',
reportUrlAsIs: true,
}) as Promise<ServerInfo | undefined>;
}
getRepo(
repo: RepoName,
errFn?: ErrorCallback
): Promise<ProjectInfo | undefined> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
return this._fetchSharedCacheURL({
url: '/projects/' + encodeURIComponent(repo),
errFn,
anonymizedUrl: '/projects/*',
}) as Promise<ProjectInfo | undefined>;
}
getProjectConfig(
repo: RepoName,
errFn?: ErrorCallback
): Promise<ConfigInfo | undefined> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
return this._fetchSharedCacheURL({
url: '/projects/' + encodeURIComponent(repo) + '/config',
errFn,
anonymizedUrl: '/projects/*/config',
}) as Promise<ConfigInfo | undefined>;
}
getRepoAccess(repo: RepoName): Promise<RepoAccessInfoMap | undefined> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
return this._fetchSharedCacheURL({
url: '/access/?project=' + encodeURIComponent(repo),
anonymizedUrl: '/access/?project=*',
}) as Promise<RepoAccessInfoMap | undefined>;
}
getRepoDashboards(
repo: RepoName,
errFn?: ErrorCallback
): Promise<DashboardInfo[] | undefined> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
return this._fetchSharedCacheURL({
url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
errFn,
anonymizedUrl: '/projects/*/dashboards?inherited',
}) as Promise<DashboardInfo[] | undefined>;
}
saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
const url = `/projects/${encodeURIComponent(repo)}/config`;
this._cache.delete(url);
return this._restApiHelper.send({
method: HttpMethod.PUT,
url,
body: config,
anonymizedUrl: '/projects/*/config',
});
}
runRepoGC(repo: RepoName): Promise<Response> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
const encodeName = encodeURIComponent(repo);
return this._restApiHelper.send({
method: HttpMethod.POST,
url: `/projects/${encodeName}/gc`,
body: '',
anonymizedUrl: '/projects/*/gc',
});
}
createRepo(config: ProjectInput & {name: RepoName}): Promise<Response> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
const encodeName = encodeURIComponent(config.name);
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: `/projects/${encodeName}`,
body: config,
anonymizedUrl: '/projects/*',
});
}
createGroup(config: GroupInput & {name: string}): Promise<Response> {
const encodeName = encodeURIComponent(config.name);
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: `/groups/${encodeName}`,
body: config,
anonymizedUrl: '/groups/*',
});
}
getGroupConfig(
group: GroupId | GroupName,
errFn?: ErrorCallback
): Promise<GroupInfo | undefined> {
return this._restApiHelper.fetchJSON({
url: `/groups/${encodeURIComponent(group)}/detail`,
errFn,
anonymizedUrl: '/groups/*/detail',
}) as Promise<GroupInfo | undefined>;
}
deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
const encodeName = encodeURIComponent(repo);
const encodeRef = encodeURIComponent(ref);
return this._restApiHelper.send({
method: HttpMethod.DELETE,
url: `/projects/${encodeName}/branches/${encodeRef}`,
body: '',
anonymizedUrl: '/projects/*/branches/*',
});
}
deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
const encodeName = encodeURIComponent(repo);
const encodeRef = encodeURIComponent(ref);
return this._restApiHelper.send({
method: HttpMethod.DELETE,
url: `/projects/${encodeName}/tags/${encodeRef}`,
body: '',
anonymizedUrl: '/projects/*/tags/*',
});
}
createRepoBranch(
name: RepoName,
branch: BranchName,
revision: BranchInput
): Promise<Response> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
const encodeName = encodeURIComponent(name);
const encodeBranch = encodeURIComponent(branch);
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: `/projects/${encodeName}/branches/${encodeBranch}`,
body: revision,
anonymizedUrl: '/projects/*/branches/*',
});
}
createRepoTag(
name: RepoName,
tag: string,
revision: TagInput
): Promise<Response> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
const encodeName = encodeURIComponent(name);
const encodeTag = encodeURIComponent(tag);
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: `/projects/${encodeName}/tags/${encodeTag}`,
body: revision,
anonymizedUrl: '/projects/*/tags/*',
});
}
getIsGroupOwner(groupName?: GroupName): Promise<boolean> {
if (!groupName) return Promise.resolve(false);
const encodeName = encodeURIComponent(groupName);
const req = {
url: `/groups/?owned&g=${encodeName}`,
anonymizedUrl: '/groups/owned&g=*',
};
return this._fetchSharedCacheURL(req).then(configs =>
hasOwnProperty(configs, groupName)
);
}
getGroupMembers(groupName: GroupId | GroupName): Promise<AccountInfo[]> {
const encodeName = encodeURIComponent(groupName);
return this._restApiHelper.fetchJSON({
url: `/groups/${encodeName}/members/`,
anonymizedUrl: '/groups/*/members',
}) as unknown as Promise<AccountInfo[]>;
}
getIncludedGroup(
groupName: GroupId | GroupName
): Promise<GroupInfo[] | undefined> {
return this._restApiHelper.fetchJSON({
url: `/groups/${encodeURIComponent(groupName)}/groups/`,
anonymizedUrl: '/groups/*/groups',
}) as Promise<GroupInfo[] | undefined>;
}
saveGroupName(groupId: GroupId | GroupName, name: string): Promise<Response> {
const encodeId = encodeURIComponent(groupId);
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: `/groups/${encodeId}/name`,
body: {name},
anonymizedUrl: '/groups/*/name',
});
}
saveGroupOwner(
groupId: GroupId | GroupName,
ownerId: string
): Promise<Response> {
const encodeId = encodeURIComponent(groupId);
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: `/groups/${encodeId}/owner`,
body: {owner: ownerId},
anonymizedUrl: '/groups/*/owner',
});
}
saveGroupDescription(
groupId: GroupId | GroupName,
description: string
): Promise<Response> {
const encodeId = encodeURIComponent(groupId);
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: `/groups/${encodeId}/description`,
body: {description},
anonymizedUrl: '/groups/*/description',
});
}
saveGroupOptions(
groupId: GroupId | GroupName,
options: GroupOptionsInput
): Promise<Response> {
const encodeId = encodeURIComponent(groupId);
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: `/groups/${encodeId}/options`,
body: options,
anonymizedUrl: '/groups/*/options',
});
}
getGroupAuditLog(
group: EncodedGroupId,
errFn?: ErrorCallback
): Promise<GroupAuditEventInfo[] | undefined> {
return this._fetchSharedCacheURL({
url: `/groups/${group}/log.audit`,
errFn,
anonymizedUrl: '/groups/*/log.audit',
}) as Promise<GroupAuditEventInfo[] | undefined>;
}
saveGroupMember(
groupName: GroupId | GroupName,
groupMember: AccountId
): Promise<AccountInfo> {
const encodeName = encodeURIComponent(groupName);
const encodeMember = encodeURIComponent(`${groupMember}`);
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: `/groups/${encodeName}/members/${encodeMember}`,
parseResponse: true,
anonymizedUrl: '/groups/*/members/*',
}) as unknown as Promise<AccountInfo>;
}
saveIncludedGroup(
groupName: GroupId | GroupName,
includedGroup: GroupId,
errFn?: ErrorCallback
): Promise<GroupInfo | undefined> {
const encodeName = encodeURIComponent(groupName);
const encodeIncludedGroup = encodeURIComponent(includedGroup);
const req = {
method: HttpMethod.PUT,
url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
errFn,
anonymizedUrl: '/groups/*/groups/*',
};
return this._restApiHelper.send(req).then(response => {
if (response?.ok) {
return this.getResponseObject(
response
) as unknown as Promise<GroupInfo>;
}
return undefined;
});
}
deleteGroupMember(
groupName: GroupId | GroupName,
groupMember: AccountId
): Promise<Response> {
const encodeName = encodeURIComponent(groupName);
const encodeMember = encodeURIComponent(`${groupMember}`);
return this._restApiHelper.send({
method: HttpMethod.DELETE,
url: `/groups/${encodeName}/members/${encodeMember}`,
anonymizedUrl: '/groups/*/members/*',
});
}
deleteIncludedGroup(
groupName: GroupId,
includedGroup: GroupId | GroupName
): Promise<Response> {
const encodeName = encodeURIComponent(groupName);
const encodeIncludedGroup = encodeURIComponent(includedGroup);
return this._restApiHelper.send({
method: HttpMethod.DELETE,
url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
anonymizedUrl: '/groups/*/groups/*',
});
}
getVersion(): Promise<string | undefined> {
return this._fetchSharedCacheURL({
url: '/config/server/version',
reportUrlAsIs: true,
}) as Promise<string | undefined>;
}
getDiffPreferences(): Promise<DiffPreferencesInfo | undefined> {
return this.getLoggedIn().then(loggedIn => {
if (loggedIn) {
return this._fetchSharedCacheURL({
url: '/accounts/self/preferences.diff',
reportUrlAsIs: true,
}) as Promise<DiffPreferencesInfo | undefined>;
}
return Promise.resolve(createDefaultDiffPrefs());
});
}
getEditPreferences(): Promise<EditPreferencesInfo | undefined> {
return this.getLoggedIn().then(loggedIn => {
if (loggedIn) {
return this._fetchSharedCacheURL({
url: '/accounts/self/preferences.edit',
reportUrlAsIs: true,
}) as Promise<EditPreferencesInfo | undefined>;
}
return Promise.resolve(createDefaultEditPrefs());
});
}
savePreferences(
prefs: PreferencesInput
): Promise<PreferencesInfo | undefined> {
// 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
.send({
method: HttpMethod.PUT,
url: '/accounts/self/preferences',
body: prefs,
reportUrlAsIs: true,
})
.then((response: Response) =>
this.getResponseObject(response).then(
obj => obj as unknown as PreferencesInfo
)
);
}
saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response> {
// Invalidate the cache.
this._cache.delete('/accounts/self/preferences.diff');
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: '/accounts/self/preferences.diff',
body: prefs,
reportUrlAsIs: true,
});
}
saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response> {
// Invalidate the cache.
this._cache.delete('/accounts/self/preferences.edit');
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: '/accounts/self/preferences.edit',
body: prefs,
reportUrlAsIs: true,
});
}
getAccount(): Promise<AccountDetailInfo | undefined> {
return this._fetchSharedCacheURL({
url: '/accounts/self/detail',
reportUrlAsIs: true,
errFn: resp => {
if (!resp || resp.status === 403) {
this._cache.delete('/accounts/self/detail');
}
},
}) as Promise<AccountDetailInfo | undefined>;
}
getAvatarChangeUrl() {
return this._fetchSharedCacheURL({
url: '/accounts/self/avatar.change.url',
reportUrlAsIs: true,
errFn: resp => {
if (!resp || resp.status === 403) {
this._cache.delete('/accounts/self/avatar.change.url');
}
},
}) as Promise<string | undefined>;
}
getExternalIds() {
return this._restApiHelper.fetchJSON({
url: '/accounts/self/external.ids',
reportUrlAsIs: true,
}) as Promise<AccountExternalIdInfo[] | undefined>;
}
deleteAccount() {
return this._restApiHelper.send({
method: HttpMethod.DELETE,
url: '/accounts/self',
reportUrlAsIs: true,
});
}
deleteAccountIdentity(id: string[]) {
return this._restApiHelper.send({
method: HttpMethod.POST,
url: '/accounts/self/external.ids:delete',
body: id,
parseResponse: true,
reportUrlAsIs: true,
}) as Promise<unknown>;
}
getAccountDetails(
userId: AccountId | EmailAddress,
errFn?: ErrorCallback
): Promise<AccountDetailInfo | undefined> {
return this._fetchSharedCacheURL({
url: `/accounts/${encodeURIComponent(userId)}/detail`,
anonymizedUrl: '/accounts/*/detail',
errFn,
}) as Promise<AccountDetailInfo | undefined>;
}
async getAccountEmails() {
const isloggedIn = await this.getLoggedIn();
if (isloggedIn) {
return this._fetchSharedCacheURL({
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._fetchSharedCacheURL({
url: '/accounts/' + email + '/emails',
reportUrlAsIs: true,
errFn,
}) as Promise<EmailInfo[] | undefined>;
}
return undefined;
});
}
addAccountEmail(email: string): Promise<Response> {
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: '/accounts/self/emails/' + encodeURIComponent(email),
anonymizedUrl: '/account/self/emails/*',
});
}
deleteAccountEmail(email: string): Promise<Response> {
return this._restApiHelper.send({
method: HttpMethod.DELETE,
url: '/accounts/self/emails/' + encodeURIComponent(email),
anonymizedUrl: '/accounts/self/email/*',
});
}
setPreferredAccountEmail(email: string): Promise<void> {
// TODO(TS): add correct error handling
const encodedEmail = encodeURIComponent(email);
const req = {
method: HttpMethod.PUT,
url: `/accounts/self/emails/${encodedEmail}/preferred`,
anonymizedUrl: '/accounts/self/emails/*/preferred',
};
return this._restApiHelper.send(req).then(() => {
// If result of getAccountEmails is in cache, update it in the cache
// so we don't have to invalidate it.
const cachedEmails = this._cache.get('/accounts/self/emails');
if (cachedEmails) {
const emails = cachedEmails.map(entry => {
if (entry.email === email) {
return {email: entry.email, preferred: true};
} else {
return {email: entry.email, preferred: false};
}
});
this._cache.set('/accounts/self/emails', emails);
}
});
}
_updateCachedAccount(obj: Partial<AccountDetailInfo>): void {
// If result of getAccount is in cache, update it in the cache
// so we don't have to invalidate it.
const cachedAccount = this._cache.get('/accounts/self/detail');
if (cachedAccount) {
// Replace object in cache with new object to force UI updates.
this._cache.set('/accounts/self/detail', {
...cachedAccount,
...obj,
});
}
}
setAccountName(name: string): Promise<void> {
// TODO(TS): add correct error handling
const req: SendJSONRequest = {
method: HttpMethod.PUT,
url: '/accounts/self/name',
body: {name},
parseResponse: true,
reportUrlAsIs: true,
};
return this._restApiHelper
.send(req)
.then(newName =>
this._updateCachedAccount({name: newName as unknown as string})
);
}
setAccountUsername(username: string): Promise<void> {
// TODO(TS): add correct error handling
const req: SendJSONRequest = {
method: HttpMethod.PUT,
url: '/accounts/self/username',
body: {username},
parseResponse: true,
reportUrlAsIs: true,
};
return this._restApiHelper
.send(req)
.then(newName =>
this._updateCachedAccount({username: newName as unknown as string})
);
}
setAccountDisplayName(displayName: string): Promise<void> {
// TODO(TS): add correct error handling
const req: SendJSONRequest = {
method: HttpMethod.PUT,
url: '/accounts/self/displayname',
body: {display_name: displayName},
parseResponse: true,
reportUrlAsIs: true,
};
return this._restApiHelper.send(req).then(newName =>
this._updateCachedAccount({
display_name: newName as unknown as string,
})
);
}
setAccountStatus(status: string): Promise<void> {
// TODO(TS): add correct error handling
const req: SendJSONRequest = {
method: HttpMethod.PUT,
url: '/accounts/self/status',
body: {status},
parseResponse: true,
reportUrlAsIs: true,
};
return this._restApiHelper
.send(req)
.then(newStatus =>
this._updateCachedAccount({status: newStatus as unknown as string})
);
}
getAccountStatus(userId: AccountId) {
return this._restApiHelper.fetchJSON({
url: `/accounts/${encodeURIComponent(userId)}/status`,
anonymizedUrl: '/accounts/*/status',
}) as Promise<string | undefined>;
}
getAccountGroups() {
return this._restApiHelper.fetchJSON({
url: '/accounts/self/groups',
reportUrlAsIs: true,
}) as Promise<GroupInfo[] | undefined>;
}
getAccountAgreements() {
return this._restApiHelper.fetchJSON({
url: '/accounts/self/agreements',
reportUrlAsIs: true,
}) as Promise<ContributorAgreementInfo[] | undefined>;
}
saveAccountAgreement(name: ContributorAgreementInput): Promise<Response> {
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: '/accounts/self/agreements',
body: name,
reportUrlAsIs: true,
});
}
getAccountCapabilities(
params?: string[]
): Promise<AccountCapabilityInfo | undefined> {
let queryString = '';
if (params) {
queryString =
'?q=' + params.map(param => encodeURIComponent(param)).join('&q=');
}
return this._fetchSharedCacheURL({
url: '/accounts/self/capabilities' + queryString,
anonymizedUrl: '/accounts/self/capabilities?q=*',
}) as Promise<AccountCapabilityInfo | undefined>;
}
getLoggedIn() {
return this.authService.authCheck();
}
getIsAdmin() {
return this.getLoggedIn()
.then(isLoggedIn => {
if (isLoggedIn) {
return this.getAccountCapabilities();
} else {
return;
}
})
.then(
(capabilities: AccountCapabilityInfo | undefined) =>
capabilities && capabilities.administrateServer
);
}
getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
return this._fetchSharedCacheURL({
url: '/config/server/preferences',
reportUrlAsIs: true,
}) as Promise<PreferencesInfo | undefined>;
}
getPreferences(): Promise<PreferencesInfo | undefined> {
return this.getLoggedIn().then(loggedIn => {
if (loggedIn) {
const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
return this._fetchSharedCacheURL(req).then(res => {
if (!res) {
return res;
}
const prefInfo = res as unknown as PreferencesInfo;
return prefInfo;
});
}
return createDefaultPreferences();
});
}
getWatchedProjects() {
return this._fetchSharedCacheURL({
url: '/accounts/self/watched.projects',
reportUrlAsIs: true,
}) as unknown as Promise<ProjectWatchInfo[] | undefined>;
}
saveWatchedProjects(
projects: ProjectWatchInfo[]
): Promise<ProjectWatchInfo[]> {
return this._restApiHelper.send({
method: HttpMethod.POST,
url: '/accounts/self/watched.projects',
body: projects,
parseResponse: true,
reportUrlAsIs: true,
}) as unknown as Promise<ProjectWatchInfo[]>;
}
deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response> {
return this._restApiHelper.send({
method: HttpMethod.POST,
url: '/accounts/self/watched.projects:delete',
body: projects,
reportUrlAsIs: true,
});
}
/**
* 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.setInProjectLookup(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,
cancelCondition?: CancelConditionCallback
): Promise<ParsedChangeInfo | undefined> {
if (!changeNum) return;
const optionsHex = await this.getChangeOptionsHex();
return this._getChangeDetail(
changeNum,
optionsHex,
errFn,
cancelCondition
).then(detail =>
// detail has ChangeViewChangeInfo type because the optionsHex always
// includes ALL_REVISIONS flag.
GrReviewerUpdatesParser.parse(detail as ChangeViewChangeInfo)
);
}
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
// This list MUST be kept in sync with getResponseFormatOptions
const options = [
ListChangesOption.ALL_COMMITS,
ListChangesOption.ALL_REVISIONS,
ListChangesOption.CHANGE_ACTIONS,
ListChangesOption.DETAILED_ACCOUNTS,
ListChangesOption.DETAILED_LABELS,
ListChangesOption.DOWNLOAD_COMMANDS,
ListChangesOption.MESSAGES,
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;
}
async getResponseFormatOptions(): Promise<string[]> {
const config = await this.getConfig(false);
// This list MUST be kept in sync with
// ChangeIT#changeDetailsDoesNotRequireIndex and IndexPreloadingUtil#CHANGE_DETAIL_OPTIONS
// This list MUST be kept in sync with getChangeOptions
const options = [
'ALL_COMMITS',
'ALL_REVISIONS',
'CHANGE_ACTIONS',
'DETAILED_LABELS',
'DETAILED_ACCOUNTS',
'DOWNLOAD_COMMANDS',
'MESSAGES',
'SUBMITTABLE',
'WEB_LINKS',
'SKIP_DIFFSTAT',
'SUBMIT_REQUIREMENTS',
];
if (this.flagService.isEnabled(KnownExperimentId.REVISION_PARENTS_DATA)) {
options.push('PARENTS');
}
if (config?.receive?.enable_signed_push) {
options.push('PUSH_CERTIFICATES');
}
return options;
}
/**
* @param optionsHex list changes options in hex
*/
_getChangeDetail(
changeNum: NumericChangeId,
optionsHex: string,
errFn?: ErrorCallback,
cancelCondition?: CancelConditionCallback
): Promise<ChangeInfo | undefined> {
return this.getChangeActionURL(changeNum, undefined, '/detail').then(
url => {
const params: FetchParams = {O: optionsHex};
const urlWithParams = this._restApiHelper.urlWithParams(url, params);
const req: FetchJSONRequest = {
url,
errFn,
cancelCondition,
params,
fetchOptions: this._etags.getOptions(urlWithParams),
anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
};
return this._restApiHelper.fetchRawJSON(req).then(response => {
if (response?.status === 304) {
return parsePrefixedJSON(
// urlWithParams already cached
this._etags.getCachedPayload(urlWithParams)!
) as unknown as ChangeInfo;
}
if (response && !response.ok) {
if (errFn) {
errFn.call(null, response);
} else {
fireServerError(response, req);
}
return undefined;
}
if (!response) {
return Promise.resolve(undefined);
}
return readResponsePayload(response).then(payload => {
if (!payload) {
return undefined;
}
this._etags.collect(urlWithParams, response, payload.raw);
// TODO(TS): Why it is always change info?
this._maybeInsertInLookup(payload.parsed as unknown as ChangeInfo);
return payload.parsed as unknown as ChangeInfo;
});
});
}
);
}
getChangeCommitInfo(changeNum: NumericChangeId, patchNum: PatchSetNum) {
return this._getChangeURLAndFetch({
changeNum,
endpoint: '/commit?links',
revision: patchNum,
reportEndpointAsIs: true,
errFn: suppress404s,
}) as Promise<CommitInfo | undefined>;
}
getChangeFiles(
changeNum: NumericChangeId,
patchRange: PatchRange
): Promise<FileNameToFileInfoMap | undefined> {
let params = undefined;
if (isMergeParent(patchRange.basePatchNum)) {
params = {parent: getParentIndex(patchRange.basePatchNum)};
} else if (patchRange.basePatchNum !== PARENT) {
params = {base: patchRange.basePatchNum};
}
return this._getChangeURLAndFetch({
changeNum,
endpoint: '/files',
revision: patchRange.patchNum,
params,
reportEndpointAsIs: true,
}) as Promise<FileNameToFileInfoMap | undefined>;
}
// TODO(TS): The output type is unclear
getChangeEditFiles(
changeNum: NumericChangeId,
patchRange: PatchRange
): Promise<{files: FileNameToFileInfoMap} | undefined> {
let endpoint = '/edit?list';
let anonymizedEndpoint = endpoint;
if (patchRange.basePatchNum !== PARENT) {
endpoint += '&base=' + encodeURIComponent(`${patchRange.basePatchNum}`);
anonymizedEndpoint += '&base=*';
}
return this._getChangeURLAndFetch({
changeNum,
endpoint,
anonymizedEndpoint,
}) as Promise<{files: FileNameToFileInfoMap} | undefined>;
}
queryChangeFiles(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
query: string,
errFn?: ErrorCallback
) {
return this._getChangeURLAndFetch({
changeNum,
endpoint: `/files?q=${encodeURIComponent(query)}`,
revision: patchNum,
anonymizedEndpoint: '/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);
}
getChangeRevisionActions(
changeNum: NumericChangeId,
patchNum: PatchSetNum
): Promise<ActionNameToActionInfoMap | undefined> {
const req: FetchChangeJSON = {
changeNum,
endpoint: '/actions',
revision: patchNum,
reportEndpointAsIs: true,
};
return this._getChangeURLAndFetch(req) as Promise<
ActionNameToActionInfoMap | undefined
>;
}
getChangeSuggestedReviewers(
changeNum: NumericChangeId,
inputVal: string,
errFn?: ErrorCallback
) {
return this._getChangeSuggestedGroup(
ReviewerState.REVIEWER,
changeNum,
inputVal,
errFn
);
}
getChangeSuggestedCCs(
changeNum: NumericChangeId,
inputVal: string,
errFn?: ErrorCallback
) {
return this._getChangeSuggestedGroup(
ReviewerState.CC,
changeNum,
inputVal,
errFn
);
}
_getChangeSuggestedGroup(
reviewerState: ReviewerState,
changeNum: NumericChangeId,
inputVal: string,
errFn?: ErrorCallback
): Promise<SuggestedReviewerInfo[] | undefined> {
// More suggestions may obscure content underneath in the reply dialog,
// see issue 10793.
const params: QuerySuggestedReviewersParams = {
n: 6,
'reviewer-state': reviewerState,
};
if (inputVal) {
params.q = inputVal;
}
return this._getChangeURLAndFetch({
changeNum,
endpoint: '/suggest_reviewers',
params,
reportEndpointAsIs: true,
errFn,
}) as Promise<SuggestedReviewerInfo[] | undefined>;
}
getChangeIncludedIn(
changeNum: NumericChangeId
): Promise<IncludedInInfo | undefined> {
return this._getChangeURLAndFetch({
changeNum,
endpoint: '/in',
reportEndpointAsIs: true,
}) as Promise<IncludedInInfo | undefined>;
}
_computeFilter(filter: string) {
if (filter?.startsWith('^')) {
filter = '&r=' + encodeURIComponent(filter);
} else if (filter) {
filter = '&m=' + encodeURIComponent(filter);
} else {
filter = '';
}
return filter;
}
_getGroupsUrl(filter: string, groupsPerPage: number, offset?: number) {
offset = offset || 0;
return (
`/groups/?n=${groupsPerPage + 1}&S=${offset}` +
this._computeFilter(filter)
);
}
_getReposUrl(
filter: string | undefined,
reposPerPage: number,
offset?: number
): [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');
}
getGroups(filter: string, groupsPerPage: number, offset?: number) {
const url = this._getGroupsUrl(filter, groupsPerPage, offset);
return this._fetchSharedCacheURL({
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);
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
// 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._fetchSharedCacheURL({
url,
anonymizedUrl: '/projects/?*',
errFn,
}) as Promise<ProjectInfoWithName[] | undefined>;
} else {
const result = await (this._fetchSharedCacheURL({
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) {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: `/projects/${encodeURIComponent(repo)}/HEAD`,
body: {ref},
anonymizedUrl: '/projects/*/HEAD',
});
}
getRepoBranches(
filter: string,
repo: RepoName,
reposBranchesPerPage: number,
offset?: number,
errFn?: ErrorCallback
): Promise<BranchInfo[] | undefined> {
offset = offset || 0;
const count = reposBranchesPerPage + 1;
filter = this._computeFilter(filter);
const encodedRepo = encodeURIComponent(repo);
const url = `/projects/${encodedRepo}/branches?n=${count}&S=${offset}${filter}`;
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
return this._restApiHelper.fetchJSON({
url,
errFn,
anonymizedUrl: '/projects/*/branches?*',
}) as Promise<BranchInfo[] | undefined>;
}
getRepoTags(
filter: string,
repo: RepoName,
reposTagsPerPage: number,
offset?: number,
errFn?: ErrorCallback
) {
offset = offset || 0;
const encodedRepo = encodeURIComponent(repo);
const n = reposTagsPerPage + 1;
const encodedFilter = this._computeFilter(filter);
const url =
`/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` + encodedFilter;
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
return this._restApiHelper.fetchJSON({
url,
errFn,
anonymizedUrl: '/projects/*/tags',
}) as unknown as Promise<TagInfo[]>;
}
getPlugins(
filter: string,
pluginsPerPage: number,
offset?: number,
errFn?: ErrorCallback
): Promise<{[pluginName: string]: PluginInfo} | undefined> {
offset = offset || 0;
const encodedFilter = this._computeFilter(filter);
const n = pluginsPerPage + 1;
const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
return this._restApiHelper.fetchJSON({
url,
errFn,
anonymizedUrl: '/plugins/?all',
});
}
getRepoAccessRights(
repoName: RepoName,
errFn?: ErrorCallback
): Promise<ProjectAccessInfo | undefined> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
return this._restApiHelper.fetchJSON({
url: `/projects/${encodeURIComponent(repoName)}/access`,
errFn,
anonymizedUrl: '/projects/*/access',
}) as Promise<ProjectAccessInfo | undefined>;
}
setRepoAccessRights(
repoName: RepoName,
repoInfo: ProjectAccessInput
): Promise<Response> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
return this._restApiHelper.send({
method: HttpMethod.POST,
url: `/projects/${encodeURIComponent(repoName)}/access`,
body: repoInfo,
anonymizedUrl: '/projects/*/access',
});
}
setRepoAccessRightsForReview(
projectName: RepoName,
projectInfo: ProjectAccessInput
): Promise<ChangeInfo> {
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: `/projects/${encodeURIComponent(projectName)}/access:review`,
body: projectInfo,
parseResponse: true,
anonymizedUrl: '/projects/*/access:review',
}) as unknown as Promise<ChangeInfo>;
}
getSuggestedGroups(
inputVal: string,
project?: RepoName,
n?: number,
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 getSuggestedAccounts(
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.getFromProjectLookup(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>;
}
addChangeReviewer(
changeNum: NumericChangeId,
reviewerID: AccountId | EmailAddress | GroupId
) {
return this._sendChangeReviewerRequest(
HttpMethod.POST,
changeNum,
reviewerID
);
}
removeChangeReviewer(
changeNum: NumericChangeId,
reviewerID: AccountId | EmailAddress | GroupId
) {
return this._sendChangeReviewerRequest(
HttpMethod.DELETE,
changeNum,
reviewerID
);
}
_sendChangeReviewerRequest(
method: HttpMethod.POST | HttpMethod.DELETE,
changeNum: NumericChangeId,
reviewerID: AccountId | EmailAddress | GroupId
) {
return this.getChangeActionURL(changeNum, undefined, '/reviewers').then(
url => {
let body;
switch (method) {
case HttpMethod.POST:
body = {reviewer: reviewerID};
break;
case HttpMethod.DELETE:
url += '/' + encodeURIComponent(reviewerID);
break;
default:
assertNever(method, `Unsupported HTTP method: ${method}`);
}
return this._restApiHelper.send({method, url, body});
}
);
}
getRelatedChanges(
changeNum: NumericChangeId,
patchNum: PatchSetNum
): Promise<RelatedChangesInfo | undefined> {
const options = '?o=SUBMITTABLE';
return this._getChangeURLAndFetch({
changeNum,
endpoint: `/related${options}`,
revision: patchNum,
reportEndpointAsIs: true,
}) as Promise<RelatedChangesInfo | undefined>;
}
getChangesSubmittedTogether(
changeNum: NumericChangeId,
options: string[] = ['NON_VISIBLE_CHANGES']
): Promise<SubmittedTogetherInfo | undefined> {
return this._getChangeURLAndFetch({
changeNum,
endpoint: `/submitted_together?o=${options.join('&o=')}`,
reportEndpointAsIs: true,
}) as Promise<SubmittedTogetherInfo | undefined>;
}
async getChangeConflicts(
changeNum: NumericChangeId
): Promise<ChangeInfo[] | undefined> {
const config = await this.getConfig(false);
if (!config?.change?.conflicts_predicate_enabled) {
return [];
}
const options = listChangesOptionsToHex(
ListChangesOption.CURRENT_REVISION,
ListChangesOption.CURRENT_COMMIT,
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>;
}
getReviewedFiles(
changeNum: NumericChangeId,
patchNum: PatchSetNum
): Promise<string[] | undefined> {
return this._getChangeURLAndFetch({
changeNum,
endpoint: '/files?reviewed',
revision: patchNum,
reportEndpointAsIs: true,
}) as Promise<string[] | undefined>;
}
saveFileReviewed(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
path: string,
reviewed: boolean
): Promise<Response> {
return this._getChangeURLAndSend({
changeNum,
method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE,
patchNum,
endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
anonymizedEndpoint: '/files/*/reviewed',
});
}
async saveChangeReview(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
review: ReviewInput,
errFn?: ErrorCallback,
fetchDetail?: boolean
) {
if (fetchDetail) {
review.response_format_options = await this.getResponseFormatOptions();
}
const promises: [Promise<void>, Promise<string>] = [
this.awaitPendingDiffDrafts(),
this.getChangeActionURL(changeNum, patchNum, '/review'),
];
return Promise.all(promises)
.then(([, url]) =>
this._restApiHelper.send({
method: HttpMethod.POST,
url,
body: review,
errFn,
parseResponse: true,
})
)
.then(payload => {
if (!payload) {
return undefined;
}
return payload as unknown as ReviewResult;
});
}
getChangeEdit(changeNum?: NumericChangeId): Promise<EditInfo | undefined> {
if (!changeNum) return Promise.resolve(undefined);
const params = {'download-commands': true};
return this.getLoggedIn().then(loggedIn => {
if (!loggedIn) {
return Promise.resolve(undefined);
}
return this._getChangeURLAndFetch(
{
changeNum,
endpoint: '/edit/',
params,
reportEndpointAsIs: true,
},
true
) as Promise<EditInfo | undefined>;
});
}
createChange(
repo: RepoName,
branch: BranchName,
subject: string,
topic?: string,
isPrivate?: boolean,
workInProgress?: boolean,
baseChange?: ChangeId,
baseCommit?: string
) {
return this._restApiHelper.send({
method: HttpMethod.POST,
url: '/changes/',
body: {
project: repo,
branch,
subject,
topic,
is_private: isPrivate,
work_in_progress: workInProgress,
base_change: baseChange,
base_commit: baseCommit,
},
parseResponse: true,
reportUrlAsIs: true,
}) as unknown as Promise<ChangeInfo | undefined>;
}
getFileContent(
changeNum: NumericChangeId,
path: string,
patchNum: PatchSetNum
): Promise<Response | Base64FileContent | undefined> {
// 404s indicate the file does not exist yet in the revision, so suppress
// them.
const promise =
patchNum === EDIT
? this._getFileInChangeEdit(changeNum, path)
: this._getFileInRevision(changeNum, path, patchNum, suppress404s);
return promise.then(res => {
if (!res || !res.ok) {
return res;
}
// The file type (used for syntax highlighting) is identified in the
// X-FYI-Content-Type header of the response.
const type = res.headers.get('X-FYI-Content-Type');
return this.getResponseObject(res).then(content => {
const strContent = content as unknown as string | null;
return {content: strContent, type, ok: true};
});
});
}
/**
* Gets a file in a specific change and revision.
*/
_getFileInRevision(
changeNum: NumericChangeId,
path: string,
patchNum: PatchSetNum,
errFn?: ErrorCallback
) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.GET,
patchNum,
endpoint: `/files/${encodeURIComponent(path)}/content`,
errFn,
headers: {Accept: 'application/json'},
anonymizedEndpoint: '/files/*/content',
});
}
/**
* Gets a file in a change edit.
*/
_getFileInChangeEdit(changeNum: NumericChangeId, path: string) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.GET,
endpoint: '/edit/' + encodeURIComponent(path),
headers: {Accept: 'application/json'},
anonymizedEndpoint: '/edit/*',
});
}
rebaseChangeEdit(changeNum: NumericChangeId) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.POST,
endpoint: '/edit:rebase',
reportEndpointAsIs: true,
});
}
deleteChangeEdit(changeNum: NumericChangeId) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.DELETE,
endpoint: '/edit',
reportEndpointAsIs: true,
});
}
restoreFileInChangeEdit(changeNum: NumericChangeId, restore_path: string) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.POST,
endpoint: '/edit',
body: {restore_path},
reportEndpointAsIs: true,
});
}
renameFileInChangeEdit(
changeNum: NumericChangeId,
old_path: string,
new_path: string
) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.POST,
endpoint: '/edit',
body: {old_path, new_path},
reportEndpointAsIs: true,
});
}
deleteFileInChangeEdit(changeNum: NumericChangeId, path: string) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.DELETE,
endpoint: '/edit/' + encodeURIComponent(path),
anonymizedEndpoint: '/edit/*',
});
}
saveChangeEdit(changeNum: NumericChangeId, path: string, contents: string) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.PUT,
endpoint: '/edit/' + encodeURIComponent(path),
body: contents,
contentType: 'text/plain',
anonymizedEndpoint: '/edit/*',
});
}
saveFileUploadChangeEdit(
changeNum: NumericChangeId,
path: string,
content: string
) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.PUT,
endpoint: '/edit/' + encodeURIComponent(path),
body: {binary_content: content},
anonymizedEndpoint: '/edit/*',
});
}
getFixPreview(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
fixReplacementInfos: FixReplacementInfo[]
): Promise<FilePathToDiffInfoMap | undefined> {
return this._getChangeURLAndSend({
method: HttpMethod.POST,
changeNum,
patchNum,
endpoint: '/fix:preview',
reportEndpointAsId: true,
headers: {Accept: 'application/json'},
parseResponse: true,
body: {fix_replacement_infos: fixReplacementInfos},
}) as Promise<FilePathToDiffInfoMap | undefined>;
}
getRobotCommentFixPreview(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
fixId: FixId
): Promise<FilePathToDiffInfoMap | undefined> {
return this._getChangeURLAndFetch({
changeNum,
revision: patchNum,
endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`,
reportEndpointAsId: true,
}) as Promise<FilePathToDiffInfoMap | undefined>;
}
applyFixSuggestion(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
fixReplacementInfos: FixReplacementInfo[]
): Promise<Response> {
return this._getChangeURLAndSend({
method: HttpMethod.POST,
changeNum,
patchNum,
endpoint: '/fix:apply',
reportEndpointAsId: true,
headers: {Accept: 'application/json'},
body: {fix_replacement_infos: fixReplacementInfos},
});
}
applyRobotFixSuggestion(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
fixId: string
): Promise<Response> {
return this._getChangeURLAndSend({
method: HttpMethod.POST,
changeNum,
patchNum,
endpoint: `/fixes/${encodeURIComponent(fixId)}/apply`,
reportEndpointAsId: true,
});
}
publishChangeEdit(changeNum: NumericChangeId) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.POST,
endpoint: '/edit:publish',
reportEndpointAsIs: true,
});
}
putChangeCommitMessage(changeNum: NumericChangeId, message: string) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.PUT,
endpoint: '/message',
body: {message},
reportEndpointAsIs: true,
});
}
deleteChangeCommitMessage(
changeNum: NumericChangeId,
messageId: ChangeMessageId
) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.DELETE,
endpoint: `/messages/${messageId}`,
reportEndpointAsIs: true,
});
}
saveChangeStarred(
changeNum: NumericChangeId,
starred: boolean
): Promise<Response> {
// Some servers may require the project name to be provided
// alongside the change number, so resolve the project name
// first.
return this.getFromProjectLookup(changeNum).then(project => {
const encodedRepoName = project ? encodeURIComponent(project) + '~' : '';
const url = `/accounts/self/starred.changes/${encodedRepoName}${changeNum}`;
return this._serialScheduler.schedule(() =>
this._restApiHelper.send({
method: starred ? HttpMethod.PUT : HttpMethod.DELETE,
url,
anonymizedUrl: '/accounts/self/starred.changes/*',
})
);
});
}
send(
method: HttpMethod,
url: string,
body?: RequestPayload,
errFn?: undefined,
contentType?: string,
headers?: Record<string, string>
): Promise<Response>;
send(
method: HttpMethod,
url: string,
body: RequestPayload | undefined,
errFn: ErrorCallback,
contentType?: string,
headers?: Record<string, string>
): Promise<Response | undefined>;
/**
* Public version of the _restApiHelper.send method preserved for plugins.
*
* @param body passed as null sometimes
* and also apparently a number. TODO (beckysiegel) remove need for
* number at least.
*/
send(
method: HttpMethod,
url: string,
body?: RequestPayload,
errFn?: ErrorCallback,
contentType?: string,
headers?: Record<string, string>
): Promise<Response | undefined> {
return this._restApiHelper.send({
method,
url,
body,
errFn,
contentType,
headers,
});
}
getDiff(
changeNum: NumericChangeId,
basePatchNum: PatchSetNum,
patchNum: PatchSetNum,
path: string,
whitespace?: IgnoreWhitespaceType,
errFn?: ErrorCallback
) {
const params: GetDiffParams = {
intraline: null,
whitespace: whitespace || 'IGNORE_NONE',
};
if (isMergeParent(basePatchNum)) {
params.parent = getParentIndex(basePatchNum);
} else if (basePatchNum !== PARENT) {
params.base = basePatchNum;
}
const endpoint = `/files/${encodeURIComponent(path)}/diff`;
const req: FetchChangeJSON = {
changeNum,
endpoint,
revision: patchNum,
errFn,
params,
anonymizedEndpoint: '/files/*/diff',
};
// Invalidate the cache if its edit patch to make sure we always get latest.
if (patchNum === EDIT) {
if (!req.fetchOptions) req.fetchOptions = {};
if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
req.fetchOptions.headers.append('Cache-Control', 'no-cache');
}
return this._getChangeURLAndFetch(req) as Promise<DiffInfo | undefined>;
}
getDiffComments(
changeNum: NumericChangeId
): Promise<{[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>;
_getDiffComments(
changeNum: NumericChangeId,
endpoint: string,
params?: FetchParams,
basePatchNum?: BasePatchSetNum,
patchNum?: PatchSetNum,
path?: string
): Promise<
| GetDiffCommentsOutput
| GetDiffRobotCommentsOutput
| {[path: string]: CommentInfo[]}
| PathToRobotCommentsInfoMap
| undefined
> {
/**
* Fetches the comments for a given patchNum.
* Helper function to make promises more legible.
*/
// We don't want to add accept header, since preloading of comments is
// working only without accept header.
const noAcceptHeader = true;
const fetchComments = (patchNum?: PatchSetNum) =>
this._getChangeURLAndFetch(
{
changeNum,
endpoint,
revision: patchNum,
reportEndpointAsIs: true,
params,
},
noAcceptHeader
) as Promise<
{[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]) || [];
// TODO(kaspern): Implement this on in the backend so this can
// be removed.
// Sort comments by date so that parent ranges can be propagated
// in a single pass.
comments = this._setRanges(comments);
if (basePatchNum === PARENT) {
baseComments = comments.filter(onlyParent);
baseComments.forEach(setPath);
}
comments = comments.filter(withoutParent);
comments.forEach(setPath);
});
promises.push(fetchPromise);
if (basePatchNum !== PARENT) {
fetchPromise = fetchComments(basePatchNum).then(response => {
baseComments = ((response && path && response[path]) || []).filter(
withoutParent
);
baseComments = this._setRanges(baseComments);
baseComments.forEach(setPath);
});
promises.push(fetchPromise);
}
return Promise.all(promises).then(() =>
Promise.resolve({
baseComments,
comments,
})
);
}
_getDiffCommentsFetchURL(
changeNum: NumericChangeId,
endpoint: string,
patchNum?: RevisionId
) {
return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
}
getPortedComments(
changeNum: NumericChangeId,
revision: RevisionId
): Promise<{[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}`);
};
return this._getChangeURLAndFetch({
changeNum,
endpoint: '/ported_comments/',
revision,
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 comments = (await this._getChangeURLAndFetch({
changeNum,
endpoint: '/ported_drafts/',
revision,
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 += '/*';
}
let body;
if (method === HttpMethod.PUT) {
body = draft;
}
if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) {
this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
}
const req = {
changeNum,
method,
patchNum,
endpoint,
body,
anonymizedEndpoint,
};
const promise = this._getChangeURLAndSend(req);
this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
if (isCreate) {
return this._failForCreate200(promise);
}
return promise;
}
getCommitInfo(
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.getFromProjectLookup(changeNum).then(project => {
// TODO(TS): unclear why project can't be null here. Fix it
let url = `/changes/${encodeURIComponent(
project as RepoName
)}~${changeNum}`;
if (revisionId) {
url += `/revisions/${revisionId}`;
}
return url;
});
}
addToAttentionSet(
changeNum: NumericChangeId,
user: AccountId | undefined | null,
reason: string
) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.POST,
endpoint: '/attention',
body: {user, reason},
reportUrlAsIs: true,
});
}
removeFromAttentionSet(
changeNum: NumericChangeId,
user: AccountId,
reason: string
) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.DELETE,
endpoint: `/attention/${user}`,
anonymizedEndpoint: '/attention/*',
body: {reason},
});
}
setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise<string> {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.PUT,
endpoint: '/topic',
body: {topic},
parseResponse: true,
reportUrlAsIs: true,
}) as unknown as Promise<string>;
}
setChangeHashtag(
changeNum: NumericChangeId,
hashtag: HashtagsInput
): Promise<Hashtag[]> {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.POST,
endpoint: '/hashtags',
body: hashtag,
parseResponse: true,
reportUrlAsIs: true,
}) as unknown as Promise<Hashtag[]>;
}
deleteAccountHttpPassword() {
return this._restApiHelper.send({
method: HttpMethod.DELETE,
url: '/accounts/self/password.http',
reportUrlAsIs: true,
});
}
generateAccountHttpPassword(): Promise<Password> {
return this._restApiHelper.send({
method: HttpMethod.PUT,
url: '/accounts/self/password.http',
body: {generate: true},
parseResponse: true,
reportUrlAsIs: true,
}) as Promise<unknown> as Promise<Password>;
}
getAccountSSHKeys() {
return this._fetchSharedCacheURL({
url: '/accounts/self/sshkeys',
reportUrlAsIs: true,
}) as Promise<unknown> as Promise<SshKeyInfo[] | undefined>;
}
addAccountSSHKey(key: string): Promise<SshKeyInfo> {
const req = {
method: HttpMethod.POST,
url: '/accounts/self/sshkeys',
body: key,
contentType: 'text/plain',
reportUrlAsIs: true,
};
return this._restApiHelper
.send(req)
.then((response: Response | undefined) => {
if (!response || (response.status < 200 && response.status >= 300)) {
return Promise.reject(new Error('error'));
}
return this.getResponseObject(
response
) as unknown as Promise<SshKeyInfo>;
})
.then(obj => {
if (!obj || !obj.valid) {
return Promise.reject(new Error('error'));
}
return obj;
});
}
deleteAccountSSHKey(id: string) {
return this._restApiHelper.send({
method: HttpMethod.DELETE,
url: '/accounts/self/sshkeys/' + id,
anonymizedUrl: '/accounts/self/sshkeys/*',
});
}
getAccountGPGKeys() {
return this._restApiHelper.fetchJSON({
url: '/accounts/self/gpgkeys',
reportUrlAsIs: true,
}) as Promise<unknown> as Promise<Record<string, GpgKeyInfo>>;
}
addAccountGPGKey(key: GpgKeysInput) {
const req = {
method: HttpMethod.POST,
url: '/accounts/self/gpgkeys',
body: key,
reportUrlAsIs: true,
};
return this._restApiHelper
.send(req)
.then(response => {
if (!response || (response.status < 200 && response.status >= 300)) {
return Promise.reject(new Error('error'));
}
return this.getResponseObject(response);
})
.then(obj => {
if (!obj) {
return Promise.reject(new Error('error'));
}
return obj;
});
}
deleteAccountGPGKey(id: GpgKeyId) {
return this._restApiHelper.send({
method: HttpMethod.DELETE,
url: `/accounts/self/gpgkeys/${id}`,
anonymizedUrl: '/accounts/self/gpgkeys/*',
});
}
deleteVote(changeNum: NumericChangeId, account: AccountId, label: string) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.DELETE,
endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
anonymizedEndpoint: '/reviewers/*/votes/*',
});
}
setDescription(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
desc: string
) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.PUT,
patchNum,
endpoint: '/description',
body: {description: desc},
reportUrlAsIs: true,
});
}
confirmEmail(token: string): Promise<string | null> {
const req = {
method: HttpMethod.PUT,
url: '/config/server/email.confirm',
body: {token},
reportUrlAsIs: true,
};
return this._restApiHelper.send(req).then(response => {
if (response?.status === 204) {
return 'Email confirmed successfully.';
}
return null;
});
}
getCapabilities(
errFn?: ErrorCallback
): Promise<CapabilityInfoMap | undefined> {
return this._restApiHelper.fetchJSON({
url: '/config/server/capabilities',
errFn,
reportUrlAsIs: true,
}) as Promise<CapabilityInfoMap | undefined>;
}
getTopMenus(): Promise<TopMenuEntryInfo[] | undefined> {
return this._fetchSharedCacheURL({
url: '/config/server/top-menus',
reportUrlAsIs: true,
}) as Promise<TopMenuEntryInfo[] | undefined>;
}
probePath(path: string) {
return fetch(new Request(path, {method: HttpMethod.HEAD})).then(
response => response.ok
);
}
startWorkInProgress(
changeNum: NumericChangeId,
message?: string
): Promise<string | undefined> {
const body = message ? {message} : {};
const req: SendRawChangeRequest = {
changeNum,
method: HttpMethod.POST,
endpoint: '/wip',
body,
reportUrlAsIs: true,
};
return this._getChangeURLAndSend(req).then(response => {
if (response?.status === 204) {
return 'Change marked as Work In Progress.';
}
return undefined;
});
}
deleteComment(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
commentID: UrlEncodedCommentId,
reason: string
) {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.POST,
patchNum,
endpoint: `/comments/${commentID}/delete`,
body: {reason},
parseResponse: true,
anonymizedEndpoint: '/comments/*/delete',
}) as unknown as Promise<CommentInfo>;
}
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.
*/
setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
this._projectLookup[changeNum] = Promise.resolve(project);
}
getFromProjectLookup(
changeNum: NumericChangeId
): Promise<RepoName | undefined> {
// Hopefully setInProjectLookup() 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
// setInProjectLookup() in the meantime. Then we can fall back to that.
const currentProjectPromise = this._projectLookup[changeNum];
if (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}
)
);
return undefined;
});
this._projectLookup[changeNum] = projectPromise;
return projectPromise;
}
// if errFn is not set, then only Response possible
_getChangeURLAndSend(
req: SendRawChangeRequest & {errFn?: undefined}
): Promise<Response>;
_getChangeURLAndSend(
req: SendRawChangeRequest
): Promise<Response | undefined>;
_getChangeURLAndSend(req: SendJSONChangeRequest): Promise<ParsedJSON>;
_getChangeURLAndSend(
req: SendChangeRequest
): Promise<ParsedJSON | Response | undefined> {
const anonymizedBaseUrl = req.patchNum
? ANONYMIZED_REVISION_BASE_URL
: ANONYMIZED_CHANGE_BASE_URL;
const anonymizedEndpoint = req.reportEndpointAsIs
? req.endpoint
: req.anonymizedEndpoint;
return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
const request: SendRequest = {
method: req.method,
url: url + req.endpoint,
body: req.body,
errFn: req.errFn,
contentType: req.contentType,
headers: req.headers,
parseResponse: req.parseResponse,
anonymizedUrl: anonymizedEndpoint
? `${anonymizedBaseUrl}${anonymizedEndpoint}`
: undefined,
};
return this._restApiHelper.send(request);
});
}
_getChangeURLAndFetch(
req: FetchChangeJSON,
noAcceptHeader?: boolean
): Promise<ParsedJSON | undefined> {
const anonymizedEndpoint = req.reportEndpointAsIs
? req.endpoint
: req.anonymizedEndpoint;
const anonymizedBaseUrl = req.revision
? ANONYMIZED_REVISION_BASE_URL
: ANONYMIZED_CHANGE_BASE_URL;
return this._changeBaseURL(req.changeNum, req.revision).then(url =>
this._restApiHelper.fetchJSON(
{
url: url + req.endpoint,
errFn: req.errFn,
params: req.params,
fetchOptions: req.fetchOptions,
anonymizedUrl: anonymizedEndpoint
? anonymizedBaseUrl + anonymizedEndpoint
: undefined,
},
noAcceptHeader
)
);
}
executeChangeAction(
changeNum: NumericChangeId,
method: HttpMethod | undefined,
endpoint: string,
patchNum?: PatchSetNum,
payload?: RequestPayload
): Promise<Response>;
executeChangeAction(
changeNum: NumericChangeId,
method: HttpMethod | undefined,
endpoint: string,
patchNum: PatchSetNum | undefined,
payload: RequestPayload | undefined,
errFn: ErrorCallback
): Promise<Response | undefined>;
executeChangeAction(
changeNum: NumericChangeId,
method: HttpMethod | undefined,
endpoint: string,
patchNum?: PatchSetNum,
payload?: RequestPayload,
errFn?: ErrorCallback
) {
return this._getChangeURLAndSend({
changeNum,
method,
patchNum,
endpoint,
body: payload,
errFn,
});
}
getBlame(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
path: string,
base?: boolean
) {
const encodedPath = encodeURIComponent(path);
return this._getChangeURLAndFetch({
changeNum,
endpoint: `/files/${encodedPath}/blame`,
revision: patchNum,
params: base ? {base: 't'} : undefined,
anonymizedEndpoint: '/files/*/blame',
}) as Promise<BlameInfo[] | undefined>;
}
/**
* Modify the given create draft request promise so that it fails and throws
* an error if the response bears HTTP status 200 instead of HTTP 201.
*
* @see Issue 7763
* @param promise The original promise.
* @return The modified promise.
*/
_failForCreate200(promise: Promise<Response>): Promise<Response> {
return promise.then(result => {
if (result.status === 200) {
// Read the response headers into an object representation.
const headers = Array.from(result.headers.entries()).reduce(
(obj, [key, val]) => {
if (!HEADER_REPORTING_BLOCK_REGEX.test(key)) {
obj[key] = val;
}
return obj;
},
{} as Record<string, string>
);
const err = new Error(
[
CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE,
JSON.stringify(headers),
].join('\n')
);
// Throw the error so that it is caught by gr-reporting.
throw err;
}
return result;
});
}
getDashboard(
repo: RepoName,
dashboard: DashboardId,
errFn?: ErrorCallback
): Promise<DashboardInfo | undefined> {
const url =
'/projects/' +
encodeURIComponent(repo) +
'/dashboards/' +
encodeURIComponent(dashboard);
return this._fetchSharedCacheURL({
url,
errFn,
anonymizedUrl: '/projects/*/dashboards/*',
}) as Promise<DashboardInfo | undefined>;
}
getDocumentationSearches(filter: string): Promise<DocResult[] | undefined> {
filter = filter.trim();
const encodedFilter = encodeURIComponent(filter);
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
return this._fetchSharedCacheURL({
url: `/Documentation/?q=${encodedFilter}`,
anonymizedUrl: '/Documentation/?*',
}) as Promise<DocResult[] | undefined>;
}
getMergeable(changeNum: NumericChangeId) {
return this._getChangeURLAndFetch({
changeNum,
endpoint: '/revisions/current/mergeable',
reportEndpointAsIs: true,
}) as Promise<MergeableInfo | undefined>;
}
deleteDraftComments(query: string): Promise<Response> {
const body: DeleteDraftCommentsInput = {query};
return this._restApiHelper.send({
method: HttpMethod.POST,
url: '/accounts/self/drafts:delete',
body,
});
}
}