UNPKG

@atomist/sdm-pack-aspect

Version:

an Atomist SDM Extension Pack for visualizing drift across an organization

240 lines (216 loc) 8.05 kB
/* * Copyright © 2019 Atomist, Inc. * * 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 { PushImpactListenerInvocation } from "@atomist/sdm"; import { Project } from "@atomist/automation-client"; import { Aspect, FP, sha256 } from "@atomist/sdm-pack-fingerprint"; import { FiveStar, Score, Scored, Scorer, ScorerReturn, Scores, ScoreWeightings, weightedCompositeScore, WeightedScore, } from "../../scorer/Score"; import { starBand } from "../../util/commonBands"; import { RepositoryScorer, RepoToScore, } from "../AspectRegistry"; import { AspectMetadata } from "../compose/commonTypes"; import * as _ from "lodash"; /** * Aspect that scores pushes or projects */ export type ScoredAspect = Aspect<WeightedScore>; export function isScoredAspectFingerprint(fp: FP): fp is FP<WeightedScore> { const maybe = fp as FP<WeightedScore>; return !!maybe && !!maybe.data && !!maybe.data.weightedScore; } /** * Score the project and the push */ export interface PushScorer extends Scorer { scorePush: (pili: PushImpactListenerInvocation) => Promise<ScorerReturn>; } /** * Default properties to configure ScoredAspect */ export const ScoredAspectDefaults: Pick<ScoredAspect, "stats" | "toDisplayableFingerprint"> = { stats: { defaultStatStatus: { entropy: false, }, basicStatsPath: "score", }, toDisplayableFingerprint: fp => starBand(fp.data.weightedScore), }; /** * Scorer that works with project content */ export interface ProjectScorer extends Scorer { scoreProject: (p: Project) => Promise<ScorerReturn>; } /** * Scorer that can be used in an aspect */ export type AspectCompatibleScorer = RepositoryScorer | ProjectScorer | PushScorer; export function isRepositoryScorer(s: AspectCompatibleScorer): s is RepositoryScorer { const maybe = s as RepositoryScorer; return !!maybe && !!maybe.scoreFingerprints; } export function isPushScorer(scorer: AspectCompatibleScorer): scorer is PushScorer { const maybe = scorer as PushScorer; return !!maybe && !!maybe.scorePush; } export function isPushOrProjectScorer(scorer: AspectCompatibleScorer): scorer is (PushScorer | ProjectScorer) { const maybe = scorer as ProjectScorer; return !!maybe && !!maybe.scoreProject || isPushScorer(scorer); } /** * Score this aspect based on projects, from low to high. * Requires no other fingerprints */ function scoringAspect( opts: { scorers: AspectCompatibleScorer[], scoreWeightings?: ScoreWeightings, } & AspectMetadata): ScoredAspect { const pushScorers = opts.scorers.filter(isPushOrProjectScorer); const repositoryScorers = opts.scorers.filter(isRepositoryScorer); return { extract: async (p, pili) => { // Just save these scores. They'll go into consolidate const scores = await pushAndProjectScoresFor(pushScorers, pili); (pili as any).scores = scores; return []; }, consolidate: async (fingerprints, p, pili) => { const emittedFingerprints: Array<FP<WeightedScore>> = []; const repoToScore: RepoToScore = { analysis: { id: p.id, fingerprints } }; const distinctNonRootPaths = _.uniq(repoToScore.analysis.fingerprints .map(fp => fp.path) .filter(p => !["", ".", undefined].includes(p)), ); for (const path of distinctNonRootPaths) { const scores = await fingerprintScoresFor( repositoryScorers.filter(rs => !rs.baseOnly), withFingerprintsOnlyUnderPath(repoToScore, path)); const scored: Scored = { scores }; const weightedScore = weightedCompositeScore(scored, opts.scoreWeightings); emittedFingerprints.push(toFingerprint(opts.name, weightedScore, path)); } // Score under root const additionalScores = { ...await fingerprintScoresFor(repositoryScorers, withFingerprintsOnlyUnderPath(repoToScore, "")), ...await fingerprintScoresFor(repositoryScorers, withFingerprintsOnlyUnderPath(repoToScore, ".")), ...await fingerprintScoresFor(repositoryScorers, withFingerprintsOnlyUnderPath(repoToScore, undefined)), // Include ones without any filter ...await fingerprintScoresFor(repositoryScorers.filter(rs => rs.scoreAll), repoToScore), }; const scores: Record<string, Score> = { ...additionalScores, ...(pili as any).scores, }; // Add rollup of subprojects emittedFingerprints.forEach(ef => { scores[ef.path + "_" + ef.name] = { name: ef.path + "_" + ef.name, score: ef.data.weightedScore as FiveStar, }; }); const scored: Scored = { scores }; const weightedScore = weightedCompositeScore(scored, opts.scoreWeightings); emittedFingerprints.push(toFingerprint(opts.name, weightedScore)); return emittedFingerprints; }, ...ScoredAspectDefaults, ...opts, }; } function toFingerprint(type: string, data: WeightedScore, path?: string): FP<WeightedScore> { return { type, name: type, path, data, sha: sha256(JSON.stringify(data.weightedScore)), }; } function withFingerprintsOnlyUnderPath(rts: RepoToScore, path: string): RepoToScore { return { analysis: { id: { ...rts.analysis.id, path, }, fingerprints: rts.analysis.fingerprints.filter(fp => fp.path === path), }, }; } export async function fingerprintScoresFor(repositoryScorers: RepositoryScorer[], toScore: RepoToScore): Promise<Scores> { const scores: Scores = {}; for (const scorer of repositoryScorers) { const sr = await scorer.scoreFingerprints(toScore); if (sr) { const score = { ...sr, name: scorer.name, category: scorer.category, }; scores[score.name] = score; } } return scores; } async function pushAndProjectScoresFor(pushScorers: Array<PushScorer | ProjectScorer>, toScore: PushImpactListenerInvocation): Promise<Scores> { const scores: Scores = {}; for (const scorer of pushScorers) { const sr = isPushScorer(scorer) ? await scorer.scorePush(toScore) : await scorer.scoreProject(toScore.project); if (sr) { const score = { ...sr, name: scorer.name, category: scorer.category, }; scores[score.name] = score; } } return scores; } export function emitScoringAspect(name: string, scorers: AspectCompatibleScorer[], scoreWeightings: ScoreWeightings): ScoredAspect | undefined { return scorers.length > 0 ? scoringAspect( { name, displayName: `Scores for ${name}`, scorers, scoreWeightings, }) : undefined; }