sarif-web-component/components/RunStore.ts

349 строки
12 KiB
TypeScript

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import {Artifact, Result, Run} from 'sarif'
import {IObservableValue, autorun, computed, observable} from 'mobx'
import {RepositoryDetails, ResultOrRuleOrMore, Rule} from './Viewer.Types'
import { getRepositoryDetailsFromRemoteUrl, isRepositoryDetailsComplete } from './getRepositoryDetailsFromRemoteUrl'
import {ITreeItem} from 'azure-devops-ui/Utilities/TreeItemProvider'
import {MobxFilter} from './FilterBar'
import {SortOrder} from 'azure-devops-ui/Table'
import { getRepoUri } from './getRepoUri'
import {tryOr} from './try'
declare module 'sarif' {
interface Run {
_index: number,
_augmented: boolean
_rulesInUse: Map<string, Rule>
_agesInUse: Map<string, {
results: any[];
treeItem: any;
name: string;
isAge: boolean;
}>
}
}
export enum SortRuleBy { Count, Name }
export const isMatch = (field: string, keywords: string[]) => !keywords.length || keywords.some(keyword => field.includes(keyword))
export class RunStore {
driverName: string
@observable sortRuleBy = SortRuleBy.Count
@observable sortColumnIndex = 1
@observable sortOrder = SortOrder.ascending
constructor(readonly run: Run, readonly logIndex, readonly filter: MobxFilter, readonly groupByAge?: IObservableValue<boolean>, readonly hideBaseline?: boolean, readonly showAge?: boolean, readonly showActions?: boolean) {
const {driver} = run.tool
this.driverName = run.properties && run.properties['logFileName'] || driver.name.replace(/^Microsoft.CodeAnalysis.Sarif.PatternMatcher$/, 'CredScan on Push')
const buildId = run.properties ? run.properties['buildId'] : 0
const artifactName = run.properties ? run.properties['artifactName'] : ''
const filePath = run.properties ? run.properties['filePath'] : ''
if (!run._augmented) {
run._rulesInUse = new Map<string, Rule>()
run._agesInUse = new Map([
['Past SLA' , { results: [], treeItem: null, name: 'Past SLA (31+ days)' , isAge: true }],
['Within SLA', { results: [], treeItem: null, name: 'Within SLA (0 - 30 days)', isAge: true }],
])
const rules = driver.rules || []
const rulesListed = new Map<string, Rule>(rules.map(rule => [rule.id, rule] as any)) // Unable to express [[string, RuleEx]].
let url: string
let repoDetails: RepositoryDetails;
try {
url = getRepoUri('-', run)
repoDetails = getRepositoryDetailsFromRemoteUrl(url)
}
catch (TypeError) { }
let resultIndex = 0;
run.results?.forEach(result => {
// Collate by Rule
const {ruleIndex} = result
const ruleId = result.ruleId ?? '(No Rule)' // Ignores 3.5.4 Hierarchical strings.
if (!run._rulesInUse.has(ruleId)) {
// Need to generate rules for some like Microsoft.CodeAnalysis.Sarif.PatternMatcher.
const rule = ruleIndex !== undefined && rules[ruleIndex] as Rule || rulesListed.get(ruleId) || { id: ruleId } as Rule
rule.isRule = true
rule.run = run // For taxa.
run._rulesInUse.set(ruleId, rule)
}
const rule = run._rulesInUse.get(ruleId)
// Try to build a 'Fix in VS Code' action
if (isRepositoryDetailsComplete(repoDetails) && buildId && artifactName && filePath) {
const fixInVsCodeAction = {
text: 'Fix in VS Code',
linkUrl: `https://waveanalysis.microsoft.com/vscode/import?buildId=${buildId}&artifactName=${artifactName}&filePath=${filePath}&organization=${repoDetails.organizationName}&project=${repoDetails.projectName}&repoName=${repoDetails.repositoryName}&runIndex=${run._index}&resultIndex=${resultIndex++}&source=1esscans`,
imageName: 'vscode',
className: 'vscode-action'
}
const fixInVsAction = {
text: 'Fix in Visual Studio',
linkUrl: `https://waveanalysis.microsoft.com/vs/import?buildId=${buildId}&artifactName=${artifactName}&filePath=${filePath}&organization=${repoDetails.organizationName}&project=${repoDetails.projectName}&repoName=${repoDetails.repositoryName}&runIndex=${run._index}&resultIndex=${resultIndex++}&source=1esscans`,
imageName: 'vs',
className: 'vs-action'
}
result.actions = [
fixInVsCodeAction,
fixInVsAction
]
}
rule.results = rule.results || []
rule.results.push(result)
// Collate by Age
const firstDetection = result.provenance?.firstDetectionTimeUtc
result.firstDetection = firstDetection ? new Date(firstDetection) : new Date()
const age = (new Date().getTime() - result.firstDetection.getTime()) / (24 * 60 * 60 * 1000) // 1 day in milliseconds
result.sla = age > 31 ? 'Past SLA' : 'Within SLA'
run._agesInUse.get(result.sla).results.push(result)
// Fill-in url from run.artifacts as needed.
const artLoc = tryOr(() => result.locations[0].physicalLocation.artifactLocation)
if (artLoc && artLoc.uri === undefined) {
const art = tryOr<Artifact>(() => run.artifacts[artLoc.index])
artLoc.uri = art.location?.uri
}
result.run = run // For result renderer to get to run.artifacts.
result._rule = rule
})
run._augmented = true
}
autorun(() => {
this.showAllRevision // Read.
const rules = this.groupByAge.get() // Slice to satisfy ref rulesTruncated.
? this.agesFiltered.slice()
: this.rulesFiltered.slice()
rules.forEach(ruleTreeItem => {
const maxLength = 3
ruleTreeItem.childItems = !ruleTreeItem.isShowAll && ruleTreeItem.childItemsAll.length > maxLength
? [
...ruleTreeItem.childItemsAll.slice(0, maxLength),
{ data: { onClick: () => {
ruleTreeItem.isShowAll = true
this.showAllRevision++
}}}
]
: ruleTreeItem.childItemsAll
})
this.rulesTruncated = rules
}, { name: 'Truncation' })
}
private filterHelper(treeItems: ITreeItem<ResultOrRuleOrMore>[]) {
const filter = this.filter.getState()
const filterKeywords = (filter.Keywords?.value ?? '').toLowerCase().split(/\s+/).filter(part => part)
const {sortColumnIndex, sortOrder} = this
treeItems.forEach(treeItem => {
// if (!treeItem.hasOwnProperty('isShowAll')) extendObservable(treeItem, { isShowAll: false })
treeItem.isShowAll = false
// Filtering logic: Show if 1) dropdowns match AND 2) any field matches text.
const isDriverMatch = isMatch(this.driverName.toLowerCase(), filterKeywords)
const resultContainer = treeItem.data as { results: Result[] }
treeItem.childItemsAll = resultContainer.results
.filter(result => {
const {_rule} = result
const ruleId = _rule.id.toLowerCase()
const ruleName = _rule.name?.toLowerCase() ?? ''
const isRuleMatch = isMatch(ruleId, filterKeywords) || isMatch(ruleName, filterKeywords)
for (const columnName in filter) {
if (columnName === 'Discussion') continue // Discussion filter does not apply to Results.
const selectedValues = filter[columnName].value
if (!Array.isArray(selectedValues)) continue
if (!selectedValues.length) continue
const map = {
Baseline: (result: Result) => result.baselineState as string || 'new', // TODO: Merge with column def.
Level: (result: Result) => result.level || 'warning',
Suppression: (result: Result) => result.suppressions?.some(s => s.status === undefined || s.status === 'accepted') ? 'suppressed' : 'unsuppressed',
Age: (result: Result) => result.sla.toLowerCase(),
}
const translatedCellValue = map[columnName] ? map[columnName](result) : result
if (!selectedValues.includes(translatedCellValue)) return false
}
const isKeywordMatch = this.columns.some(column => {
const field = column.filterString(result).toLowerCase()
return isMatch(field, filterKeywords)
})
return isDriverMatch || isRuleMatch || isKeywordMatch
})
.map(result => ({ data: result })) // Can cache the result here.
treeItem.childItemsAll.sort((treeItemLeft, treeItemRight) => {
const resultToValue = this.columns[sortColumnIndex].sortString
const valueLeft = resultToValue(treeItemLeft.data as Result)
const valueRight = resultToValue(treeItemRight.data as Result)
const inverter = sortOrder === SortOrder.ascending ? 1 : -1
return inverter * valueLeft.localeCompare(valueRight)
})
return treeItem as ITreeItem<ResultOrRuleOrMore>
})
const treeItemsVisible = treeItems.filter(rule => rule.childItemsAll.length)
treeItemsVisible.sort(this.sortRuleBy === SortRuleBy.Count
? (a, b) => b.childItemsAll.length - a.childItemsAll.length
: (a, b) => (a.data as Rule).id.localeCompare((b.data as Rule).id)
)
treeItemsVisible.forEach((rule, i) => rule.expanded = i === 0)
return treeItemsVisible
}
@computed get agesFiltered() {
const treeItems = [...this.run._agesInUse.values()]
.map(age => {
const treeItem = age.treeItem = age.treeItem || {
data: age,
expanded: false,
}
return treeItem as ITreeItem<ResultOrRuleOrMore>
})
return this.filterHelper(treeItems)
}
@computed get rulesFiltered() {
const treeItems = [...this.run._rulesInUse.values()]
.map(rule => {
const treeItem = rule.treeItem = rule.treeItem || {
data: rule,
expanded: false,
}
return treeItem as ITreeItem<ResultOrRuleOrMore>
})
return this.filterHelper(treeItems)
}
@computed get filteredCount() {
return this.rulesFiltered.reduce((total, rule) => total + rule.childItemsAll.length, 0)
}
@observable showAllRevision = 0
@observable.ref rulesTruncated = [] as ITreeItem<ResultOrRuleOrMore>[] // Technically ITreeItem<Rule>[], ref assuming immutable array.
@computed get columns() {
const columns = [
{
id: 'Path',
filterString: (result: Result) => tryOr<string>(
() => `${result.locations[0].logicalLocations[0].fullyQualifiedName} ${tryOr(() => {
const {index} = result.locations[0].physicalLocation.artifactLocation
return result.run.artifacts[index].description.text
}, '')}`,
() => result.locations[0].physicalLocation.artifactLocation.uri,
'',
),
sortString: (result: Result) => tryOr<string>(
() => result.locations[0].physicalLocation.artifactLocation.uri,
'\u2014', // Using escape as VS Packaging munges the char.
),
width: -3,
}
]
if (this.showAge && this.groupByAge.get()) {
columns.push({
id: 'Rule',
filterString: (result: Result) => {
const rule = result._rule
return `${rule.id || rule.guid} ${rule.name ?? ''}`
},
sortString: (result: Result) => {
const rule = result._rule
return `${rule.id || rule.guid} ${rule.name ?? ''}`
},
width: -2,
})
}
columns.push({
id: 'Details',
filterString: (result: Result) => {
// TODO: Support templated messages.
const message = tryOr<string>(
() => result.message.markdown,
() => result.message.text, // Can be a constant?
'')
const snippet = tryOr<string>(
() => result.locations[0].physicalLocation.contextRegion.snippet.text,
() => result.locations[0].physicalLocation.region.snippet.text,
'')
return `${message} ${snippet}`
},
sortString: (result: Result) => result.message.text as string || '',
width: -5,
})
if (this.showActions) {
columns.push({
id: 'Actions',
filterString: (result: Result) => '',
sortString: (result: Result) => '',
width: -2,
})
}
if (!this.hideBaseline) {
columns.push({
id: 'Baseline',
filterString: (result: Result) => result.baselineState as string || 'new',
sortString: (result: Result) => result.baselineState as string || 'new',
width: -1,
})
}
const hasWorkItemUris = this.run.results && this.run.results.some(result => result.workItemUris && !!result.workItemUris.length)
if (hasWorkItemUris) {
columns.push({
id: 'Bug',
filterString: (result: Result) => '',
sortString: (result: Result) => '',
width: -1,
})
}
if (this.showAge && !this.groupByAge.get()) {
columns.push({
id: 'Age',
filterString: (result: Result) => result.sla,
sortString: (result: Result) => result.sla,
width: -1,
})
}
if (this.showAge) {
columns.push({
id: 'First Observed', // Consider using name instead of id
filterString: (result: Result) => result.firstDetection.toLocaleDateString(),
sortString: (result: Result) => result.firstDetection.getTime().toString(),
width: -1,
})
}
return columns
}
}