blob: 5eb9cac402c6173e3b8a3f8feb40bf02905e60bd [file] [log] [blame]
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
Action,
Category,
CheckResult as CheckResultApi,
CheckRun as CheckRunApi,
Fix,
Link,
LinkIcon,
Replacement,
RunStatus,
} from '../../api/checks';
import {
PatchSetNumber,
RevisionPatchSetNum,
FixReplacementInfo,
} from '../../api/rest-api';
import {CommentSide} from '../../constants/constants';
import {FixSuggestionInfo, DraftInfo} from '../../types/common';
import {OpenFixPreviewEventDetail} from '../../types/events';
import {isDefined} from '../../types/types';
import {PROVIDED_FIX_ID, createNew} from '../../utils/comment-util';
import {assert, assertIsDefined, assertNever} from '../../utils/common-util';
import {fire} from '../../utils/event-util';
import {CheckResult, CheckRun, RunResult} from './checks-model';
export interface ChecksIcon {
name: string;
filled?: boolean;
}
export function iconForLink(linkIcon?: LinkIcon): ChecksIcon {
if (linkIcon === undefined) return {name: 'open_in_new'};
switch (linkIcon) {
case LinkIcon.EXTERNAL:
return {name: 'open_in_new'};
case LinkIcon.IMAGE:
return {name: 'image', filled: true};
case LinkIcon.HISTORY:
return {name: 'history'};
case LinkIcon.DOWNLOAD:
return {name: 'download'};
case LinkIcon.DOWNLOAD_MOBILE:
return {name: 'system_update'};
case LinkIcon.HELP_PAGE:
return {name: 'help'};
case LinkIcon.REPORT_BUG:
return {name: 'bug_report', filled: true};
case LinkIcon.CODE:
return {name: 'code'};
case LinkIcon.FILE_PRESENT:
return {name: 'file_present'};
case LinkIcon.VIEW_TIMELINE:
return {name: 'view_timeline'};
default:
// We don't throw an assertion error here, because plugins don't have to
// be written in TypeScript, so we may encounter arbitrary strings for
// linkIcon.
return {name: 'open_in_new'};
}
}
export function tooltipForLink(linkIcon?: LinkIcon) {
if (linkIcon === undefined) return 'Link to details';
switch (linkIcon) {
case LinkIcon.EXTERNAL:
return 'Link to details';
case LinkIcon.IMAGE:
return 'Link to image';
case LinkIcon.HISTORY:
return 'Link to result history';
case LinkIcon.DOWNLOAD:
return 'Download';
case LinkIcon.DOWNLOAD_MOBILE:
return 'Download';
case LinkIcon.HELP_PAGE:
return 'Link to help page';
case LinkIcon.REPORT_BUG:
return 'Link for reporting a problem';
case LinkIcon.CODE:
return 'Link to code';
case LinkIcon.FILE_PRESENT:
return 'Link to file';
default:
// We don't throw an assertion error here, because plugins don't have to
// be written in TypeScript, so we may encounter arbitrary strings for
// linkIcon.
return 'Link to details';
}
}
function pleaseFixMessage(result: RunResult) {
return `Please fix this ${result.category} reported by ${result.checkName}: ${result.summary}
${result.message}`;
}
export function createPleaseFixComment(result: RunResult): DraftInfo {
const pointer = result.codePointers?.[0];
assertIsDefined(pointer, 'codePointer');
return {
...createNew(pleaseFixMessage(result), true),
path: pointer.path,
patch_set: result.patchset as RevisionPatchSetNum,
side: CommentSide.REVISION,
line: pointer.range.end_line ?? pointer.range.start_line,
range: pointer.range,
};
}
export function createFixAction(
target: EventTarget,
result?: RunResult
): Action | undefined {
if (!result?.patchset) return;
if (!result?.fixes) return;
const fixSuggestions = result.fixes
.map(f => rectifyFix(f, result?.checkName))
.filter(isDefined);
if (fixSuggestions.length === 0) return;
const eventDetail: OpenFixPreviewEventDetail = {
patchNum: result.patchset as PatchSetNumber,
fixSuggestions,
onCloseFixPreviewCallbacks: [],
};
return {
name: 'Show Fix',
callback: () => {
fire(target, 'open-fix-preview', eventDetail);
return undefined;
},
};
}
export function rectifyFix(
fix: Fix | undefined,
checkName: string | undefined
): FixSuggestionInfo | undefined {
if (!fix?.replacements || !checkName) return undefined;
const replacements = fix.replacements
.map(rectifyReplacement)
.filter(isDefined);
if (replacements.length === 0) return undefined;
return {
description: [fix.description, `Fix provided by ${checkName}`]
.filter(Boolean)
.join(' - '),
fix_id: PROVIDED_FIX_ID,
replacements,
};
}
export function rectifyReplacement(
r: Replacement | undefined
): FixReplacementInfo | undefined {
if (!r?.path) return undefined;
if (!r?.range) return undefined;
if (r?.replacement === undefined) return undefined;
if (!Number.isInteger(r.range.start_line)) return undefined;
if (!Number.isInteger(r.range.end_line)) return undefined;
if (!Number.isInteger(r.range.start_character)) return undefined;
if (!Number.isInteger(r.range.end_character)) return undefined;
return r;
}
export function worstCategory(run: CheckRunApi) {
if (hasResultsOf(run, Category.ERROR)) return Category.ERROR;
if (hasResultsOf(run, Category.WARNING)) return Category.WARNING;
if (hasResultsOf(run, Category.INFO)) return Category.INFO;
if (hasResultsOf(run, Category.SUCCESS)) return Category.SUCCESS;
return undefined;
}
export function isCategory(
catStat?: Category | RunStatus
): catStat is Category {
return (
catStat === Category.ERROR ||
catStat === Category.WARNING ||
catStat === Category.INFO ||
catStat === Category.SUCCESS
);
}
export function isStatus(catStat?: Category | RunStatus): catStat is RunStatus {
return (
catStat === RunStatus.COMPLETED ||
catStat === RunStatus.RUNNABLE ||
catStat === RunStatus.SCHEDULED ||
catStat === RunStatus.RUNNING
);
}
export function labelFor(catStat: Category | RunStatus) {
switch (catStat) {
case Category.ERROR:
return 'error';
case Category.INFO:
return 'info';
case Category.WARNING:
return 'warning';
case Category.SUCCESS:
return 'success';
case RunStatus.COMPLETED:
return 'completed';
case RunStatus.RUNNABLE:
return 'runnable';
case RunStatus.RUNNING:
return 'running';
case RunStatus.SCHEDULED:
return 'scheduled';
default:
assertNever(catStat, `Unsupported category/status: ${catStat}`);
}
}
export function iconFor(catStat: Category | RunStatus): ChecksIcon {
switch (catStat) {
case Category.ERROR:
return {name: 'error', filled: true};
case Category.INFO:
return {name: 'info'};
case Category.WARNING:
return {name: 'warning', filled: true};
case Category.SUCCESS:
return {name: 'check_circle'};
// Note that this is only for COMPLETED without results!
case RunStatus.COMPLETED:
return {name: 'check_circle'};
case RunStatus.RUNNABLE:
return {name: ''};
case RunStatus.RUNNING:
return {name: 'timelapse'};
case RunStatus.SCHEDULED:
return {name: 'pending_actions'};
default:
assertNever(catStat, `Unsupported category/status: ${catStat}`);
}
}
export enum PRIMARY_STATUS_ACTIONS {
RERUN = 'rerun',
RUN = 'run',
}
export function toCanonicalAction(action: Action, status: RunStatus) {
let name = action.name.toLowerCase();
if (status === RunStatus.COMPLETED && (name === 'run' || name === 're-run')) {
name = PRIMARY_STATUS_ACTIONS.RERUN;
}
return {...action, name};
}
export function headerForStatus(status: RunStatus) {
switch (status) {
case RunStatus.COMPLETED:
return 'Completed';
case RunStatus.RUNNABLE:
return 'Not run';
case RunStatus.RUNNING:
return 'Running';
case RunStatus.SCHEDULED:
return 'Scheduled';
default:
assertNever(status, `Unsupported status: ${status}`);
}
}
function primaryActionName(status: RunStatus) {
switch (status) {
case RunStatus.COMPLETED:
return PRIMARY_STATUS_ACTIONS.RERUN;
case RunStatus.RUNNABLE:
return PRIMARY_STATUS_ACTIONS.RUN;
case RunStatus.RUNNING:
case RunStatus.SCHEDULED:
return undefined;
default:
assertNever(status, `Unsupported status: ${status}`);
}
}
export function primaryRunAction(run?: CheckRun): Action | undefined {
if (!run) return undefined;
return runActions(run).filter(
action => !action.disabled && action.name === primaryActionName(run.status)
)[0];
}
export function runActions(run?: CheckRun | RunResult): Action[] {
if (!run?.actions) return [];
return run.actions.map(action => toCanonicalAction(action, run.status));
}
export function iconForRun(run: CheckRun) {
if (run.status !== RunStatus.COMPLETED) {
return iconFor(run.status);
} else {
const category = run.worstCategory;
return category ? iconFor(category) : iconFor(run.status);
}
}
export function hasCompleted(run: CheckRun) {
return run.status === RunStatus.COMPLETED;
}
export function isRunningOrScheduled(run: CheckRun) {
return run.status === RunStatus.RUNNING || run.status === RunStatus.SCHEDULED;
}
export function isRunningScheduledOrCompleted(run: CheckRun) {
return (
run.status === RunStatus.COMPLETED ||
run.status === RunStatus.RUNNING ||
run.status === RunStatus.SCHEDULED
);
}
export function hasCompletedWithoutResults(run: CheckRun) {
return run.status === RunStatus.COMPLETED && (run.results ?? []).length === 0;
}
export function hasCompletedWith(run: CheckRun, category: Category) {
return hasCompleted(run) && hasResultsOf(run, category);
}
export function hasResults(run: CheckRun): boolean {
return (run.results ?? []).length > 0;
}
export function allResults(runs: CheckRun[]): CheckResult[] {
return runs.reduce(
(results: CheckResult[], run: CheckRun) => [
...results,
...(run.results ?? []),
],
[]
);
}
export function hasResultsOf(run: CheckRunApi, category: Category) {
return getResultsOf(run, category).length > 0;
}
export function getResultsOf(run: CheckRunApi, category: Category) {
return (run.results ?? []).filter(r => r.category === category);
}
export function compareByWorstCategory(a: CheckRun, b: CheckRun) {
const catComp = catLevel(b.worstCategory) - catLevel(a.worstCategory);
if (catComp !== 0) return catComp;
const statusComp = runLevel(b.status) - runLevel(a.status);
return statusComp;
}
function catLevel(cat?: Category) {
if (!cat) return -1;
switch (cat) {
case Category.SUCCESS:
return 0;
case Category.INFO:
return 1;
case Category.WARNING:
return 2;
case Category.ERROR:
return 3;
}
}
function runLevel(status: RunStatus) {
switch (status) {
case RunStatus.COMPLETED:
return 0;
case RunStatus.RUNNABLE:
return 1;
case RunStatus.RUNNING:
return 2;
case RunStatus.SCHEDULED:
return 3;
default:
assertNever(status, `Unsupported status: ${status}`);
}
}
export interface AttemptDetail {
attempt?: AttemptChoice;
icon?: ChecksIcon;
}
export interface AttemptInfo {
latestAttempt: AttemptChoice;
isSingleAttempt: boolean;
attempts: AttemptDetail[];
}
export type AttemptChoice = number | 'latest' | 'all';
export const ALL_ATTEMPTS = 'all' as AttemptChoice;
export const LATEST_ATTEMPT = 'latest' as AttemptChoice;
export function isAttemptChoice(x: number | string): x is AttemptChoice {
if (typeof x === 'string') {
return x === ALL_ATTEMPTS || x === LATEST_ATTEMPT;
}
if (typeof x === 'number') {
return x >= 0;
}
return false;
}
export function stringToAttemptChoice(
s?: string | null
): AttemptChoice | undefined {
if (s === undefined) return undefined;
if (s === null) return undefined;
if (s === '') return undefined;
if (isAttemptChoice(s)) return s;
const n = Number(s);
if (isAttemptChoice(n)) return n;
return undefined;
}
export function attemptChoiceLabel(attempt: AttemptChoice): string {
if (attempt === LATEST_ATTEMPT) return 'Latest Attempt';
if (attempt === ALL_ATTEMPTS) return 'All Attempts';
return `Attempt ${attempt}`;
}
export function sortAttemptDetails(a: AttemptDetail, b: AttemptDetail): number {
return sortAttemptChoices(a.attempt, b.attempt);
}
export function sortAttemptChoices(
a?: AttemptChoice,
b?: AttemptChoice
): number {
if (a === b) return 0;
if (a === undefined) return -1;
if (b === undefined) return 1;
if (a === LATEST_ATTEMPT) return -1;
if (b === LATEST_ATTEMPT) return 1;
if (a === ALL_ATTEMPTS) return -1;
if (b === ALL_ATTEMPTS) return 1;
assert(typeof a === 'number', `unexpected attempt ${a}`);
assert(typeof b === 'number', `unexpected attempt ${b}`);
return a - b;
}
export function createAttemptMap(runs: CheckRunApi[]) {
const map = new Map<string, AttemptInfo>();
for (const run of runs) {
const value = map.get(run.checkName);
const detail: AttemptDetail = {
attempt: run.attempt ?? 0,
icon: iconForRun(fromApiToInternalRun(run)),
};
if (value === undefined) {
map.set(run.checkName, {
latestAttempt: run.attempt ?? 0,
isSingleAttempt: true,
attempts: [detail],
});
continue;
}
if (!run.attempt || !value.latestAttempt) {
throw new Error(
'If multiple run attempts are provided, ' +
'then each run must have the "attempt" property set.'
);
}
value.isSingleAttempt = false;
if (
value.latestAttempt !== 'all' &&
value.latestAttempt !== 'latest' &&
run.attempt > value.latestAttempt
) {
value.latestAttempt = run.attempt;
}
value.attempts.push(detail);
}
return map;
}
export function fromApiToInternalRun(run: CheckRunApi): CheckRun {
return {
...run,
pluginName: 'fake',
internalRunId: 'fake',
isSingleAttempt: false,
isLatestAttempt: false,
attemptDetails: [],
worstCategory: worstCategory(run),
results: (run.results ?? []).map(fromApiToInternalResult),
};
}
export function fromApiToInternalResult(result: CheckResultApi): CheckResult {
return {
...result,
internalResultId: 'fake',
};
}
function allPrimaryLinks(result?: CheckResultApi): Link[] {
return (result?.links ?? []).filter(link => link.primary);
}
export function firstPrimaryLink(result?: CheckResultApi): Link | undefined {
return allPrimaryLinks(result).find(link => link.icon === LinkIcon.EXTERNAL);
}
export function otherPrimaryLinks(result?: CheckResultApi): Link[] {
const first = firstPrimaryLink(result);
return allPrimaryLinks(result).filter(link => link !== first);
}
export function secondaryLinks(result?: CheckResultApi): Link[] {
return (result?.links ?? []).filter(link => !link.primary);
}
export function computeIsExpandable(result?: CheckResultApi) {
const hasMessage = !!result?.message;
const hasMultipleLinks = (result?.links ?? []).length > 1;
const hasPointers = (result?.codePointers ?? []).length > 0;
const hasFixes = (result?.fixes ?? []).length > 0;
return hasMessage || hasMultipleLinks || hasPointers || hasFixes;
}