@atomist/sdm-pack-aspect
Version:
an Atomist SDM Extension Pack for visualizing drift across an organization
329 lines (310 loc) • 11 kB
text/typescript
/*
* 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 { sha256 } from "@atomist/sdm-pack-fingerprint";
import { Language } from "@atomist/sdm-pack-sloc/lib/slocReport";
import * as _ from "lodash";
import { RepositoryScorer } from "../aspect/AspectRegistry";
import { isCodeMetricsFingerprint } from "../aspect/common/codeMetrics";
import { findReviewCommentCountFingerprint } from "../aspect/common/reviewerAspect";
import { CodeOfConductType } from "../aspect/community/codeOfConduct";
import {
hasNoLicense,
isLicenseFingerprint,
} from "../aspect/community/license";
import {
countGlobMatches,
isGlobMatchFingerprint,
} from "../aspect/compose/globAspect";
import { BranchCountType } from "../aspect/git/branchCount";
import { daysSince } from "../aspect/git/dateUtils";
import { GitRecencyType } from "../aspect/git/gitActivity";
import {
AlwaysIncludeCategory,
FiveStar,
} from "./Score";
import { adjustBy } from "./scoring";
export const CommunityCategory: string = "community";
export const CodeCategory: string = "code";
/**
* Use to anchor scores to penalize repositories about which we know little.
* Typically weighted > default x1
*/
export function anchorScoreAt(score: FiveStar): RepositoryScorer {
const scoreFingerprints = async () => {
return {
reason: `Weight to ${score} stars to penalize repositories about which we know little`,
score,
};
};
return {
name: "anchor",
category: AlwaysIncludeCategory,
scoreFingerprints,
};
}
/**
* Penalize repositories for not having a recent commit.
* days is the number of days since the latest commit to the default branch
* that will cost 1 star.
*/
export function requireRecentCommit(opts: { days: number }): RepositoryScorer {
const scoreFingerprints = async repo => {
const grt = repo.analysis.fingerprints.find(fp => fp.type === GitRecencyType);
if (!grt) {
return undefined;
}
if (!grt.data.lastCommitTime) {
return undefined;
}
const date = new Date(grt.data.lastCommitTime);
const days = daysSince(date);
return {
score: adjustBy((1 - days) / (opts.days || 1)),
reason: `Last commit ${days} days ago`,
};
};
return {
name: "recency",
scoreFingerprints,
baseOnly: true,
};
}
/**
* Limit languages used in a project
*/
export function limitLanguages(opts: { limit: number, baseOnly?: boolean }): RepositoryScorer {
const scoreFingerprints = async repo => {
const cm = repo.analysis.fingerprints.find(isCodeMetricsFingerprint);
if (!cm) {
return undefined;
}
return {
score: adjustBy(opts.limit - cm.data.languages.length),
reason: `Found ${cm.data.languages.length} languages: ${cm.data.languages.map(l => l.language.name).join(",")}`,
};
};
return {
name: "multi-language",
scoreFingerprints,
baseOnly: opts.baseOnly,
};
}
/**
* Penalize repositories for having too many lines of code.
* The limit is the number of lines of code required to drop one star.
* The chosen limit will depend on team preferences: For example,
* are we trying to do microservices?
*/
export function limitLinesOfCode(opts: { limit: number, baseOnly?: boolean }): RepositoryScorer {
const scoreFingerprints = async repo => {
const cm = repo.analysis.fingerprints.find(isCodeMetricsFingerprint);
if (!cm) {
return undefined;
}
return {
score: adjustBy(-cm.data.lines / opts.limit),
reason: `Found ${cm.data.lines} total lines of code`,
};
};
return {
name: "total-loc",
category: CodeCategory,
scoreFingerprints,
baseOnly: opts.baseOnly,
};
}
/**
* Penalize repositories for having too many lines of code in the given language
*/
export function limitLinesOfCodeIn(opts: { limit: number, language: Language, freeAmount?: number }): RepositoryScorer {
const scoreFingerprints = async repo => {
const cm = repo.analysis.fingerprints.find(isCodeMetricsFingerprint);
if (!cm) {
return undefined;
}
const target = cm.data.languages.find(l => l.language.name === opts.language.name);
const targetLoc = target ? target.total : 0;
return {
score: adjustBy(((opts.freeAmount || 0) - targetLoc) / opts.limit),
reason: `Found ${targetLoc} lines of ${opts.language.name}`,
};
};
return {
name: `limit-${opts.language.name} (${opts.limit})`,
scoreFingerprints,
};
}
/**
* Penalize repositories for having an excessive number of git branches
*/
export function penalizeForExcessiveBranches(opts: { branchLimit: number }): RepositoryScorer {
const scoreFingerprints = async repo => {
const branchCount = repo.analysis.fingerprints.find(f => f.type === BranchCountType);
if (!branchCount) {
return undefined;
}
// You get the first 2 branches for free. After that they start to cost
const score = adjustBy(-(branchCount.data.count - 2) / opts.branchLimit);
return branchCount ? {
score,
reason: `${branchCount.data.count} branches: Should not have more than ${opts.branchLimit}`,
} : undefined;
};
return {
name: BranchCountType,
scoreFingerprints,
baseOnly: true,
};
}
/**
* Penalize repositories for having more than 1 virtual project,
* as identified by a VirtualProjectFinder
*/
export const PenalizeMonorepos: RepositoryScorer = {
scoreFingerprints: async repo => {
const distinctPaths = _.uniq(repo.analysis.fingerprints.map(t => t.path)).length;
return {
score: adjustBy(1 - distinctPaths / 2),
reason: distinctPaths > 1 ?
`${distinctPaths} virtual projects: Prefer one project per repository` :
"Single project in repository",
};
},
name: "monorepo",
scoreAll: true,
};
/**
* Penalize repositories without a license file
*/
export const PenalizeNoLicense: RepositoryScorer = {
name: "require-license",
category: CommunityCategory,
baseOnly: true,
scoreFingerprints: async repo => {
const license = repo.analysis.fingerprints.find(isLicenseFingerprint);
const bad = !license || hasNoLicense(license.data);
return {
score: bad ? 1 : 5,
reason: bad ? "Repositories should have a license" : "Repository has a license",
};
},
};
/**
* Penalize repositories without a code of conduct file
*/
export const PenalizeNoCodeOfConduct: RepositoryScorer =
requireAspectOfType({
type: CodeOfConductType,
category: CommunityCategory,
reason: "Repos should have a code of conduct",
baseOnly: true,
});
/**
* Penalize repositories that don't have this type of aspect.
* If data is provided, check that the sha matches the default sha-ing of this
* data payload
*/
export function requireAspectOfType(opts: {
type: string,
reason: string,
data?: any,
category?: string,
baseOnly?: boolean,
}): RepositoryScorer {
const scoreFingerprints = async repo => {
const found = repo.analysis.fingerprints.find(fp => fp.type === opts.type &&
(opts.data ? fp.sha === sha256(JSON.stringify(opts.data)) : true));
const score: FiveStar = !!found ? 5 : 1;
return {
score,
reason: !found ? opts.reason : "Satisfactory",
};
};
return {
name: `${opts.type}-required`,
category: opts.category,
scoreFingerprints,
baseOnly: opts.baseOnly,
};
}
/**
* Penalize repositories without matches for the glob pattern.
* Depends on globAspect
*/
export function requireGlobAspect(opts: { glob: string, category?: string, baseOnly?: boolean }): RepositoryScorer {
const scoreFingerprints = async repo => {
const globs = repo.analysis.fingerprints.filter(isGlobMatchFingerprint);
const found = globs
.filter(gf => gf.data.glob === opts.glob)
.filter(f => f.data.matches.length > 0);
const score: FiveStar = !!found ? 5 : 1;
return {
score,
reason: !found ? `Should have file for ${opts.glob}` : "Satisfactory",
};
};
return {
name: `${opts.glob}-required`,
category: opts.category,
scoreFingerprints,
baseOnly: opts.baseOnly,
};
}
/**
* Penalize for each point lost in these reviewers
*/
export function penalizeForReviewViolations(opts: { reviewerName: string, violationsPerPointLost: number }): RepositoryScorer {
return {
name: opts.reviewerName,
scoreFingerprints: async repo => {
const found = findReviewCommentCountFingerprint(opts.reviewerName, repo.analysis.fingerprints);
if (!found) {
return undefined;
}
const score = adjustBy(-found.data.count / opts.violationsPerPointLost);
return {
score,
reason: `${found.data.count} review comments found for ${opts.reviewerName}`,
};
},
};
}
/**
* Convenient function to emit scorers for all reviewers
*/
export function penalizeForAllReviewViolations(opts: { reviewerNames: string[], violationsPerPointLost: number }): RepositoryScorer[] {
return opts.reviewerNames.map(reviewerName => penalizeForReviewViolations({
reviewerName,
violationsPerPointLost: opts.violationsPerPointLost,
}));
}
/**
* Use for a file pattern or something within files we don't want
*/
export function penalizeGlobMatches(opts: { name?: string, type: string, pointsLostPerMatch: number}): RepositoryScorer {
const scoreFingerprints = async repo => {
const count = countGlobMatches(repo.analysis.fingerprints, opts.type);
const score = adjustBy(-count * opts.pointsLostPerMatch);
return {
score,
reason: `${count} matches for glob typed ${opts.type}: Should have none`,
};
};
return {
name: opts.name || opts.type,
scoreFingerprints,
};
}