blob: 5ebc13c614e2f20913735a96f574b376d5161f0c [file] [log] [blame]
/**
* @license
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
catchError,
filter,
switchMap,
takeUntil,
takeWhile,
throttleTime,
withLatestFrom,
} from 'rxjs/operators';
import {
Action,
ChangeData,
CheckResult,
CheckRun,
ChecksApiConfig,
ChecksProvider,
FetchResponse,
ResponseCode,
} from '../../api/checks';
import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
import {
ChecksPatchset,
checksSelectedPatchsetNumber$,
checkToPluginMap$,
updateStateSetError,
updateStateSetLoading,
updateStateSetNotLoggedIn,
updateStateSetPatchset,
updateStateSetProvider,
updateStateSetResults,
updateStateUpdateResult,
} from './checks-model';
import {
BehaviorSubject,
combineLatest,
from,
Observable,
of,
Subject,
timer,
} from 'rxjs';
import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
import {getCurrentRevision} from '../../utils/change-util';
import {getShaByPatchNum} from '../../utils/patch-set-util';
import {assertIsDefined} from '../../utils/common-util';
import {ReportingService} from '../gr-reporting/gr-reporting';
import {routerPatchNum$} from '../router/router-model';
import {Execution} from '../../constants/reporting';
import {fireAlert, fireEvent} from '../../utils/event-util';
export class ChecksService {
private readonly providers: {[name: string]: ChecksProvider} = {};
private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
private checkToPluginMap = new Map<string, string>();
private changeNum?: NumericChangeId;
private latestPatchNum?: PatchSetNumber;
private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
constructor(readonly reporting: ReportingService) {
changeNum$.subscribe(x => (this.changeNum = x));
checkToPluginMap$.subscribe(map => {
this.checkToPluginMap = map;
});
combineLatest([routerPatchNum$, latestPatchNum$]).subscribe(
([routerPs, latestPs]) => {
this.latestPatchNum = latestPs;
if (latestPs === undefined) {
this.setPatchset(undefined);
} else if (typeof routerPs === 'number') {
this.setPatchset(routerPs);
} else {
this.setPatchset(latestPs);
}
}
);
document.addEventListener('visibilitychange', () => {
this.documentVisibilityChange$.next(undefined);
});
document.addEventListener('reload', () => {
this.reloadAll();
});
}
setPatchset(num?: PatchSetNumber) {
updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
}
reload(pluginName: string) {
this.reloadSubjects[pluginName].next();
}
reloadAll() {
Object.keys(this.providers).forEach(key => this.reload(key));
}
reloadForCheck(checkName?: string) {
if (!checkName) return;
const plugin = this.checkToPluginMap.get(checkName);
if (plugin) this.reload(plugin);
}
updateResult(pluginName: string, run: CheckRun, result: CheckResult) {
updateStateUpdateResult(pluginName, run, result, ChecksPatchset.LATEST);
updateStateUpdateResult(pluginName, run, result, ChecksPatchset.SELECTED);
}
triggerAction(action?: Action, run?: CheckRun) {
if (!action?.callback) return;
if (!this.changeNum) return;
const patchSet = run?.patchset ?? this.latestPatchNum;
if (!patchSet) return;
const promise = action.callback(
this.changeNum,
patchSet,
run?.attempt,
run?.externalId,
run?.checkName,
action.name
);
// If plugins return undefined or not a promise, then show no toast.
if (!promise?.then) return;
fireAlert(document, `Triggering action '${action.name}' ...`);
from(promise)
// If the action takes longer than 5 seconds, then most likely the
// user is either not interested or the result not relevant anymore.
.pipe(takeUntil(timer(5000)))
.subscribe(result => {
if (result.errorMessage || result.message) {
fireAlert(document, `${result.message ?? result.errorMessage}`);
} else {
fireEvent(document, 'hide-alert');
}
if (result.shouldReload) {
this.reloadForCheck(run?.checkName);
}
});
}
register(
pluginName: string,
provider: ChecksProvider,
config: ChecksApiConfig
) {
if (this.providers[pluginName]) {
console.warn(
`Plugin '${pluginName}' was trying to register twice as a Checks UI provider. Ignored.`
);
return;
}
this.providers[pluginName] = provider;
this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
}
initFetchingOfData(
pluginName: string,
config: ChecksApiConfig,
patchset: ChecksPatchset
) {
const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
// Various events should trigger fetching checks from the provider:
// 1. Change number and patchset number changes.
// 2. Specific reload requests.
// 3. Regular polling starting with an initial fetch right now.
// 4. A hidden Gerrit tab becoming visible.
combineLatest([
changeNum$,
patchset === ChecksPatchset.LATEST
? latestPatchNum$
: checksSelectedPatchsetNumber$,
this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
timer(0, pollIntervalMs),
this.documentVisibilityChange$,
])
.pipe(
takeWhile(_ => !!this.providers[pluginName]),
filter(_ => document.visibilityState !== 'hidden'),
withLatestFrom(change$),
switchMap(
([[changeNum, patchNum], change]): Observable<FetchResponse> => {
if (!change || !changeNum || !patchNum) return of(this.empty());
if (typeof patchNum !== 'number') return of(this.empty());
assertIsDefined(change.revisions, 'change.revisions');
const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
// Sometimes patchNum is updated earlier than change, so change
// revisions don't have patchNum yet
if (!patchsetSha) return of(this.empty());
const data: ChangeData = {
changeNumber: changeNum,
patchsetNumber: patchNum,
patchsetSha,
repo: change.project,
commitMessage: getCurrentRevision(change)?.commit?.message,
changeInfo: change as ChangeInfo,
};
return this.fetchResults(pluginName, data, patchset);
}
),
catchError(e => {
// This should not happen and is really severe, because it means that
// the Observable has terminated and we won't recover from that. No
// further attempts to fetch results for this plugin will be made.
this.reporting.error(e, `checks-service crash for ${pluginName}`);
return of(this.createErrorResponse(pluginName, e));
})
)
.subscribe(response => {
switch (response.responseCode) {
case ResponseCode.ERROR: {
const message = response.errorMessage ?? '-';
this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
plugin: pluginName,
message,
});
updateStateSetError(pluginName, message, patchset);
break;
}
case ResponseCode.NOT_LOGGED_IN: {
assertIsDefined(response.loginCallback, 'loginCallback');
this.reporting.reportExecution(Execution.CHECKS_API_NOT_LOGGED_IN, {
plugin: pluginName,
});
updateStateSetNotLoggedIn(
pluginName,
response.loginCallback,
patchset
);
break;
}
case ResponseCode.OK: {
updateStateSetResults(
pluginName,
response.runs ?? [],
response.actions ?? [],
response.links ?? [],
patchset
);
break;
}
}
});
}
private empty(): FetchResponse {
return {
responseCode: ResponseCode.OK,
runs: [],
};
}
private createErrorResponse(
pluginName: string,
message: object
): FetchResponse {
return {
responseCode: ResponseCode.ERROR,
errorMessage:
`Error message from plugin '${pluginName}':` +
` ${JSON.stringify(message)}`,
};
}
private fetchResults(
pluginName: string,
data: ChangeData,
patchset: ChecksPatchset
): Observable<FetchResponse> {
updateStateSetLoading(pluginName, patchset);
const timer = this.reporting.getTimer('ChecksPluginFetch');
const fetchPromise = this.providers[pluginName]
.fetch(data)
.then(response => {
timer.end({pluginName});
return response;
});
return from(fetchPromise).pipe(
catchError(e => of(this.createErrorResponse(pluginName, e)))
);
}
}