blob: 6b3505695510d0335fdbfc9d1d0a99c4dda96fb4 [file] [log] [blame]
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
AttemptChoice,
AttemptDetail,
createAttemptMap,
LATEST_ATTEMPT,
sortAttemptDetails,
} from './checks-util';
import {assertIsDefined} from '../../utils/common-util';
import {select} from '../../utils/observable-util';
import {Finalizable} from '../../services/registry';
import {
BehaviorSubject,
combineLatest,
from,
Observable,
of,
Subject,
timer,
} from 'rxjs';
import {
catchError,
filter,
switchMap,
take,
takeUntil,
takeWhile,
timeout,
throttleTime,
withLatestFrom,
} from 'rxjs/operators';
import {
Action,
CheckResult as CheckResultApi,
CheckRun as CheckRunApi,
Link,
ChangeData,
ChecksApiConfig,
ChecksProvider,
FetchResponse,
ResponseCode,
Category,
RunStatus,
} from '../../api/checks';
import {ChangeModel} from '../change/change-model';
import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
import {getCurrentRevision} from '../../utils/change-util';
import {getShaByPatchNum} from '../../utils/patch-set-util';
import {ReportingService} from '../../services/gr-reporting/gr-reporting';
import {Execution, Interaction, Timing} from '../../constants/reporting';
import {fireAlert, fireEvent} from '../../utils/event-util';
import {RouterModel} from '../../services/router/router-model';
import {Model} from '../model';
import {define} from '../dependency';
import {
ChecksPlugin,
ChecksUpdate,
PluginsModel,
} from '../plugins/plugins-model';
import {ChangeViewModel} from '../views/change';
/**
* The checks model maintains the state of checks for two patchsets: the latest
* and (if different) also for the one selected in the checks tab. So we need
* the distinction in a lot of places for checks about whether the code affects
* the checks data of the LATEST or the SELECTED patchset.
*/
export enum ChecksPatchset {
LATEST = 'LATEST',
SELECTED = 'SELECTED',
}
export interface CheckResult extends CheckResultApi {
/**
* Internally we want to uniquely identify a run with an id, for example when
* efficiently re-rendering lists of runs in the UI.
*/
internalResultId: string;
}
export interface CheckRun extends CheckRunApi {
/**
* For convenience we attach the name of the plugin to each run.
*/
pluginName: string;
/**
* Internally we want to uniquely identify a result with an id, for example
* when efficiently re-rendering lists of results in the UI.
*/
internalRunId: string;
/**
* Is this run attempt the latest attempt for the check, i.e. does it have
* the highest attempt number among all checks with the same name?
*/
isLatestAttempt: boolean;
/**
* Is this the only attempt for the check, i.e. we don't have data for other
* attempts?
*/
isSingleAttempt: boolean;
/**
* List of all attempts for the same check, ordered by attempt number.
*/
attemptDetails: AttemptDetail[];
results?: CheckResult[];
}
// This is a convenience type for working with results, because when working
// with a bunch of results you will typically also want to know about the run
// properties. So you can just combine them with {...run, ...result}.
export type RunResult = CheckRun & CheckResult;
export const checksModelToken = define<ChecksModel>('checks-model');
export interface ChecksProviderState {
pluginName: string;
loading: boolean;
/**
* Allows to distinguish whether loading:true is the *first* time of loading
* something for this provider. Or just a subsequent background update.
* Note that this is initially true even before loading is being set to true,
* so you may want to check loading && firstTimeLoad.
*/
firstTimeLoad: boolean;
/** Presence of errorMessage implicitly means that the provider is in ERROR state. */
errorMessage?: string;
/** Presence of loginCallback implicitly means that the provider is in NOT_LOGGED_IN state. */
loginCallback?: () => void;
summaryMessage?: string;
runs: CheckRun[];
actions: Action[];
links: Link[];
}
interface ChecksState {
/** Checks data for the latest patchset. */
pluginStateLatest: {
[name: string]: ChecksProviderState;
};
/**
* Checks data for the selected patchset. Note that `checksSelected$` below
* falls back to the data for the latest patchset, if no patchset is selected.
*/
pluginStateSelected: {
[name: string]: ChecksProviderState;
};
}
/**
* Android's Checks Plugin has a 15s timeout internally. So we are using
* something slightly larger, so that we get a proper error from the plugin,
* if they run into timeout issues.
*/
const FETCH_RESULT_TIMEOUT_MS = 16000;
/**
* Can be used in `reduce()` to collect all results from all runs from all
* providers into one array.
*/
function collectRunResults(
allResults: RunResult[],
providerState: ChecksProviderState
) {
return [
...allResults,
...providerState.runs.reduce((results: RunResult[], run: CheckRun) => {
const runResults: RunResult[] =
run.results?.map(r => {
return {...run, ...r};
}) ?? [];
return results.concat(runResults ?? []);
}, []),
];
}
export interface ErrorMessages {
/* Maps plugin name to error message. */
[name: string]: string;
}
export class ChecksModel extends Model<ChecksState> implements Finalizable {
private readonly providers: {[name: string]: ChecksProvider} = {};
private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
private checkToPluginMap = new Map<string, string>();
// visible for testing
changeNum?: NumericChangeId;
// visible for testing
latestPatchNum?: PatchSetNumber;
private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
private readonly reloadListener: () => void;
private readonly visibilityChangeListener: () => void;
public checksSelectedPatchsetNumber$ = select(
this.changeViewModel.checksPatchset$,
ps => ps
);
public checksSelectedAttemptNumber$ = select(
this.changeViewModel.attempt$,
attempt => attempt ?? LATEST_ATTEMPT
);
public runFilterRegexp$ = select(
this.changeViewModel.filter$,
filter => filter ?? ''
);
public checksLatest$ = select(this.state$, state => state.pluginStateLatest);
public checksSelected$ = select(
combineLatest([this.state$, this.changeViewModel.checksPatchset$]),
([state, ps]) => {
const checksPs = ps ? ChecksPatchset.SELECTED : ChecksPatchset.LATEST;
return this.getPluginState(state, checksPs);
}
);
public aPluginHasRegistered$ = select(
this.checksLatest$,
state => Object.keys(state).length > 0
);
private firstLoadCompleted$ = select(this.checksLatest$, state => {
const providers = Object.values(state);
if (providers.length === 0) return false;
if (providers.some(p => p.loading || p.firstTimeLoad)) return false;
return true;
});
public someProvidersAreLoadingFirstTime$ = select(this.checksLatest$, state =>
Object.values(state).some(
provider => provider.loading && provider.firstTimeLoad
)
);
public someProvidersAreLoadingLatest$ = select(this.checksLatest$, state =>
Object.values(state).some(providerState => providerState.loading)
);
public someProvidersAreLoadingSelected$ = select(
this.checksSelected$,
state => Object.values(state).some(providerState => providerState.loading)
);
public errorMessageLatest$ = select(
this.checksLatest$,
state =>
Object.values(state).find(
providerState => providerState.errorMessage !== undefined
)?.errorMessage
);
public errorMessagesLatest$ = select(this.checksLatest$, state => {
const errorMessages: ErrorMessages = {};
for (const providerState of Object.values(state)) {
if (providerState.errorMessage === undefined) continue;
errorMessages[providerState.pluginName] = providerState.errorMessage;
}
return errorMessages;
});
public loginCallbackLatest$ = select(
this.checksLatest$,
state =>
Object.values(state).find(
providerState => providerState.loginCallback !== undefined
)?.loginCallback
);
public topLevelActionsLatest$ = select(this.checksLatest$, state =>
Object.values(state).reduce(
(allActions: Action[], providerState: ChecksProviderState) => [
...allActions,
...providerState.actions,
],
[]
)
);
public topLevelMessagesLatest$ = select(this.checksLatest$, state => {
const messages = Object.values(state).map(
providerState => providerState.summaryMessage
);
return messages.filter(m => !!m) as string[];
});
public topLevelActionsSelected$ = select(this.checksSelected$, state =>
Object.values(state).reduce(
(allActions: Action[], providerState: ChecksProviderState) => [
...allActions,
...providerState.actions,
],
[]
)
);
public topLevelLinksSelected$ = select(this.checksSelected$, state =>
Object.values(state).reduce(
(allLinks: Link[], providerState: ChecksProviderState) => [
...allLinks,
...providerState.links,
],
[]
)
);
public allRunsLatestPatchset$ = select(this.checksLatest$, state =>
Object.values(state).reduce(
(allRuns: CheckRun[], providerState: ChecksProviderState) => [
...allRuns,
...providerState.runs,
],
[]
)
);
public allRunsSelectedPatchset$ = select(this.checksSelected$, state =>
Object.values(state).reduce(
(allRuns: CheckRun[], providerState: ChecksProviderState) => [
...allRuns,
...providerState.runs,
],
[]
)
);
public allRunsLatestPatchsetLatestAttempt$ = select(
this.allRunsLatestPatchset$,
runs => runs.filter(run => run.isLatestAttempt)
);
public checkToPluginMap$ = select(this.checksLatest$, state => {
const map = new Map<string, string>();
for (const [pluginName, providerState] of Object.entries(state)) {
for (const run of providerState.runs) {
map.set(run.checkName, pluginName);
}
}
return map;
});
public allResultsSelected$ = select(this.checksSelected$, state =>
Object.values(state)
.reduce(collectRunResults, [])
.filter(r => r !== undefined)
);
public allResultsLatest$ = select(this.checksLatest$, state =>
Object.values(state)
.reduce(collectRunResults, [])
.filter(r => r !== undefined)
);
public allResults$ = select(
combineLatest([
this.checksSelectedPatchsetNumber$,
this.allResultsSelected$,
this.allResultsLatest$,
]),
([selectedPs, selected, latest]) =>
selectedPs ? [...selected, ...latest] : latest
);
constructor(
readonly routerModel: RouterModel,
readonly changeViewModel: ChangeViewModel,
readonly changeModel: ChangeModel,
readonly reporting: ReportingService,
readonly pluginsModel: PluginsModel
) {
super({
pluginStateLatest: {},
pluginStateSelected: {},
});
this.reporting.time(Timing.CHECKS_LOAD);
this.subscriptions = [
this.changeModel.changeNum$.subscribe(x => (this.changeNum = x)),
this.changeModel.latestPatchNum$.subscribe(
x => (this.latestPatchNum = x)
),
this.pluginsModel.checksPlugins$.subscribe(plugins => {
for (const plugin of plugins) {
this.register(plugin);
}
}),
this.pluginsModel.checksAnnounce$.subscribe(a =>
this.reload(a.pluginName)
),
this.pluginsModel.checksUpdate$.subscribe(u => this.updateResult(u)),
this.checkToPluginMap$.subscribe(map => {
this.checkToPluginMap = map;
}),
this.firstLoadCompleted$
.pipe(
filter(completed => !!completed),
take(1),
withLatestFrom(this.checksLatest$)
)
.subscribe(([_, state]) => this.reportStats(state)),
];
this.visibilityChangeListener = () => {
this.documentVisibilityChange$.next(undefined);
};
document.addEventListener(
'visibilitychange',
this.visibilityChangeListener
);
this.reloadListener = () => this.reloadAll();
document.addEventListener('reload', this.reloadListener);
}
private reportStats(state: {[name: string]: ChecksProviderState}) {
const stats = {
providerCount: 0,
providerErrorCount: 0,
providerLoginCount: 0,
providerActionCount: 0,
providerLinkCount: 0,
errorCount: 0,
warningCount: 0,
infoCount: 0,
successCount: 0,
runnableCount: 0,
scheduledCount: 0,
runningCount: 0,
completedCount: 0,
};
const providers = Object.values(state);
for (const provider of providers) {
stats.providerCount++;
if (provider.errorMessage) stats.providerErrorCount++;
if (provider.loginCallback) stats.providerLoginCount++;
if (provider.actions?.length) stats.providerActionCount++;
if (provider.links?.length) stats.providerLinkCount++;
for (const run of provider.runs) {
if (run.status === RunStatus.RUNNABLE) stats.runnableCount++;
if (run.status === RunStatus.SCHEDULED) stats.scheduledCount++;
if (run.status === RunStatus.RUNNING) stats.runningCount++;
if (run.status === RunStatus.COMPLETED) stats.completedCount++;
for (const result of run.results ?? []) {
if (result.category === Category.ERROR) stats.errorCount++;
if (result.category === Category.WARNING) stats.warningCount++;
if (result.category === Category.INFO) stats.infoCount++;
if (result.category === Category.SUCCESS) stats.successCount++;
}
}
}
this.reporting.timeEnd(Timing.CHECKS_LOAD, stats);
this.reporting.reportInteraction(Interaction.CHECKS_STATS, stats);
}
override finalize() {
document.removeEventListener('reload', this.reloadListener);
document.removeEventListener(
'visibilitychange',
this.visibilityChangeListener
);
super.finalize();
}
// Must only be used by the checks service or whatever is in control of this
// model.
updateStateSetProvider(pluginName: string, patchset: ChecksPatchset) {
const nextState = {...this.getState()};
const pluginState = this.getPluginState(nextState, patchset);
pluginState[pluginName] = {
pluginName,
loading: false,
firstTimeLoad: true,
runs: [],
actions: [],
links: [],
};
this.setState(nextState);
}
getPluginState(
state: ChecksState,
patchset: ChecksPatchset = ChecksPatchset.LATEST
) {
if (patchset === ChecksPatchset.LATEST) {
state.pluginStateLatest = {...state.pluginStateLatest};
return state.pluginStateLatest;
} else {
state.pluginStateSelected = {...state.pluginStateSelected};
return state.pluginStateSelected;
}
}
updateStateSetLoading(pluginName: string, patchset: ChecksPatchset) {
const nextState = {...this.getState()};
const pluginState = this.getPluginState(nextState, patchset);
pluginState[pluginName] = {
...pluginState[pluginName],
loading: true,
};
this.setState(nextState);
}
updateStateSetError(
pluginName: string,
errorMessage: string,
patchset: ChecksPatchset
) {
const nextState = {...this.getState()};
const pluginState = this.getPluginState(nextState, patchset);
pluginState[pluginName] = {
...pluginState[pluginName],
loading: false,
firstTimeLoad: false,
errorMessage,
loginCallback: undefined,
runs: [],
actions: [],
};
this.setState(nextState);
}
updateStateSetNotLoggedIn(
pluginName: string,
loginCallback: () => void,
patchset: ChecksPatchset
) {
const nextState = {...this.getState()};
const pluginState = this.getPluginState(nextState, patchset);
pluginState[pluginName] = {
...pluginState[pluginName],
loading: false,
firstTimeLoad: false,
errorMessage: undefined,
loginCallback,
runs: [],
actions: [],
};
this.setState(nextState);
}
updateStateSetResults(
pluginName: string,
runs: CheckRunApi[],
actions: Action[] = [],
links: Link[] = [],
summaryMessage: string | undefined,
patchset: ChecksPatchset
) {
// Protect against plugins not respecting required fields.
runs = runs.filter(run => !!run.checkName && !!run.status);
const attemptMap = createAttemptMap(runs);
for (const attemptInfo of attemptMap.values()) {
attemptInfo.attempts.sort(sortAttemptDetails);
}
const nextState = {...this.getState()};
const pluginState = this.getPluginState(nextState, patchset);
const oldState = pluginState[pluginName];
pluginState[pluginName] = {
...oldState,
loading: false,
firstTimeLoad: oldState.loading ? false : oldState.firstTimeLoad,
errorMessage: undefined,
loginCallback: undefined,
runs: runs.map(run => {
const runId = `${run.checkName}-${run.change}-${run.patchset}-${run.attempt}`;
const attemptInfo = attemptMap.get(run.checkName);
assertIsDefined(attemptInfo, 'attemptInfo');
return {
...run,
attempt: run.attempt ?? 0,
pluginName,
internalRunId: runId,
isLatestAttempt: attemptInfo.latestAttempt === (run.attempt ?? 0),
isSingleAttempt: attemptInfo.isSingleAttempt,
attemptDetails: attemptInfo.attempts,
results: (run.results ?? []).map((result, i) => {
return {
...result,
internalResultId: `${runId}-${i}`,
};
}),
};
}),
actions: [...actions],
links: [...links],
summaryMessage,
};
this.setState(nextState);
}
updateStateUpdateResult(
pluginName: string,
updatedRun: CheckRunApi,
updatedResult: CheckResultApi,
patchset: ChecksPatchset
) {
const nextState = {...this.getState()};
const pluginState = this.getPluginState(nextState, patchset);
let runUpdated = false;
const runs: CheckRun[] = pluginState[pluginName].runs.map(run => {
if (run.change !== updatedRun.change) return run;
if (run.patchset !== updatedRun.patchset) return run;
if (run.attempt !== updatedRun.attempt) return run;
if (run.checkName !== updatedRun.checkName) return run;
let resultUpdated = false;
const results: CheckResult[] = (run.results ?? []).map(result => {
if (
result.externalId &&
result.externalId === updatedResult.externalId
) {
runUpdated = true;
resultUpdated = true;
return {
...updatedResult,
internalResultId: result.internalResultId,
};
}
return result;
});
return resultUpdated ? {...run, results} : run;
});
if (!runUpdated) return;
pluginState[pluginName] = {
...pluginState[pluginName],
runs,
};
this.setState(nextState);
}
updateStateSetPatchset(num?: PatchSetNumber) {
this.changeViewModel.updateState({
checksPatchset: num === this.latestPatchNum ? undefined : num,
});
}
updateStateSetAttempt(attemptNumberSelected: AttemptChoice) {
this.changeViewModel.updateState({attempt: attemptNumberSelected});
}
updateStateSetRunFilter(runFilterRegexp: string) {
this.changeViewModel.updateState({filter: runFilterRegexp});
}
reload(pluginName: string) {
this.reloadSubjects[pluginName].next();
}
reloadAll() {
for (const key of Object.keys(this.providers)) {
this.reload(key);
}
}
reloadForCheck(checkName?: string) {
if (!checkName) return;
const plugin = this.checkToPluginMap.get(checkName);
if (plugin) this.reload(plugin);
}
updateResult(update: ChecksUpdate) {
const {pluginName, run, result} = update;
this.updateStateUpdateResult(
pluginName,
run,
result,
ChecksPatchset.LATEST
);
this.updateStateUpdateResult(
pluginName,
run,
result,
ChecksPatchset.SELECTED
);
}
triggerAction(action: Action, run: CheckRun | undefined, context: string) {
if (!action?.callback) return;
if (!this.changeNum) return;
const patchSet = run?.patchset ?? this.latestPatchNum;
if (!patchSet) return;
this.reporting.reportInteraction(Interaction.CHECKS_ACTION_TRIGGERED, {
checkName: run?.checkName,
actionName: action.name,
context,
});
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(checksPlugin: ChecksPlugin) {
const {pluginName, provider, config} = checksPlugin;
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);
this.updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
this.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.
this.subscriptions.push(
combineLatest([
this.changeModel.change$,
patchset === ChecksPatchset.LATEST
? this.changeModel.latestPatchNum$
: this.checksSelectedPatchsetNumber$,
this.reloadSubjects[pluginName].pipe(
throttleTime(1000, undefined, {trailing: true, leading: true})
),
pollIntervalMs === 0 ? from([0]) : timer(0, pollIntervalMs),
this.documentVisibilityChange$,
])
.pipe(
takeWhile(_ => !!this.providers[pluginName]),
filter(_ => document.visibilityState !== 'hidden'),
switchMap(([change, patchNum]): Observable<FetchResponse> => {
if (!change || !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: change?._number,
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(`checks-model crash for ${pluginName}`, e);
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,
});
this.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,
}
);
this.updateStateSetNotLoggedIn(
pluginName,
response.loginCallback,
patchset
);
break;
}
case ResponseCode.OK: {
this.updateStateSetResults(
pluginName,
response.runs ?? [],
response.actions ?? [],
response.links ?? [],
response.summaryMessage,
patchset
);
break;
}
}
})
);
}
private empty(): FetchResponse {
return {
responseCode: ResponseCode.OK,
runs: [],
};
}
private createErrorResponse(pluginName: string, error: Error): FetchResponse {
return {
responseCode: ResponseCode.ERROR,
errorMessage:
`Error message from plugin '${pluginName}':` +
` ${JSON.stringify(error)}`,
};
}
private fetchResults(
pluginName: string,
data: ChangeData,
patchset: ChecksPatchset
): Observable<FetchResponse> {
this.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(timeout(FETCH_RESULT_TIMEOUT_MS))
.pipe(catchError(e => of(this.createErrorResponse(pluginName, e))));
}
}