Rewrite/refactor for v5, migrate to TypeScript (MAJOR)

Set Jest config file in package script

Include module path

Include tests in project folders

Remove index module exports

Hardcode configuration parameters

Move parameter binding into main run function

Use alias imports

Run test sequentially

Remove cleanup (async conflict)

Revert Jest option

Increase test timeout to 15 seconds
This commit is contained in:
Paul Hatcherian 2022-04-03 17:49:40 -04:00
parent dc8f0c5755
commit 31f4e3fdf0
74 changed files with 9422 additions and 4342 deletions

View file

@ -0,0 +1,47 @@
import { ActionConfig } from "../ActionConfig";
import { CommitInfo } from "./CommitInfo";
import { CommitInfoSet } from "./CommitInfoSet";
import { DefaultVersionClassifier } from "./DefaultVersionClassifier";
import { ReleaseInformation } from "./ReleaseInformation";
import { VersionClassification } from "./VersionClassification";
import { VersionType } from "./VersionType";
export class BumpAlwaysVersionClassifier extends DefaultVersionClassifier {
constructor(config: ActionConfig) {
super(config);
// Placeholder for consistency
}
public override async ClassifyAsync(lastRelease: ReleaseInformation, commitSet: CommitInfoSet): Promise<VersionClassification> {
if (lastRelease.currentPatch !== null) {
return new VersionClassification(VersionType.None, 0, false, <number>lastRelease.currentMajor, <number>lastRelease.currentMinor, <number>lastRelease.currentPatch);
}
let { major, minor, patch } = lastRelease;
let type = VersionType.None;
if (commitSet.commits.length === 0) {
return new VersionClassification(type, 0, false, major, minor, patch);
}
for (let commit of commitSet.commits.reverse()) {
if (this.majorPattern(commit)) {
major += 1;
minor = 0;
patch = 0;
type = VersionType.Major;
} else if (this.minorPattern(commit)) {
minor += 1;
patch = 0;
type = VersionType.Minor;
} else {
patch += 1;
type = VersionType.Patch;
}
}
return new VersionClassification(type, 0, true, major, minor, patch);
}
}

View file

@ -0,0 +1,27 @@
/** Represents information about a commit */
export class CommitInfo {
/**
* Creates a new commit information instance
* @param hash - The hash of the commit
* @param subject - The subject of the commit message
* @param body - The body of the commit message
* @param author - The author's name
* @param authorEmail - The author's email
* @param authorDate - The date the commit was authored
* @param committer - The committer's name
* @param committerEmail - The committer's email
* @param committerDate - The date the commit was committed
* @param tags - List of any tags associated with this commit
*/
constructor(
public hash: string,
public subject: string,
public body: string,
public author: string,
public authorEmail: string,
public authorDate: Date,
public committer: string,
public committerEmail: string,
public committerDate: Date,
public tags: string[]) { }
}

View file

@ -0,0 +1,9 @@
import { CommitInfo } from "./CommitInfo";
/** Represents information about a set of commits */
export class CommitInfoSet {
constructor(
public changed: boolean,
public commits: CommitInfo[]
){}
}

View file

@ -0,0 +1,14 @@
import { CommitInfoSet } from "./CommitInfoSet";
/**
* Defines a provider to retrieve commit information for a range of commits
*/
export interface CommitsProvider {
/**
* Gets the commit information for a range of commits
* @param startHash - The hash of commit of the last release, result should be exclusive
* @param endHash - The hash of the current commit, result should be inclusive
*/
GetCommitsAsync(startHash: string, endHash: string): Promise<CommitInfoSet>;
}

View file

@ -0,0 +1,13 @@
/** Resolver to obtain information about the repository */
export interface CurrentCommitResolver {
/**
* Resolves the current commit.
* @returns The current commit.
*/
ResolveAsync(): Promise<string>;
/**
* Returns true if the repository is empty
* @returns True if the repository is empty
*/
IsEmptyRepoAsync(): Promise<boolean>;
}

View file

@ -0,0 +1,94 @@
import { ActionConfig } from "../ActionConfig";
import { cmd } from "../CommandRunner";
import { CommitInfo } from "./CommitInfo";
import { CommitInfoSet } from "./CommitInfoSet";
import { CommitsProvider } from "./CommitsProvider";
export class DefaultCommitsProvider implements CommitsProvider {
private changePath: string;
constructor(config: ActionConfig) {
this.changePath = config.changePath;
}
async GetCommitsAsync(startHash: string, endHash: string): Promise<CommitInfoSet> {
const logSplitter = `@@@START_RECORD`
const formatPlaceholders = Object.entries({
hash: '%H',
subject: '%s',
body: '%b',
author: '%an',
authorEmail: '%ae',
authorDate: '%aI',
committer: '%cn',
committerEmail: '%ce',
committerDate: '%cI',
tags: '%d'
});
const pretty = logSplitter + '%n' + formatPlaceholders
.map(x => `@@@${x[0]}%n${x[1]}`)
.join('%n');
var logCommand = `git log --pretty="${pretty}" --author-date-order ${(startHash === '' ? endHash : `${startHash}..${endHash}`)}`;
if (this.changePath !== '') {
logCommand += ` -- ${this.changePath}`;
}
const log = await cmd(logCommand);
const entries = log
.split(logSplitter)
.slice(1);
const commits = entries.map(entry => {
const fields: any = entry
.split(`@@@`)
.slice(1)
.reduce((acc: any, value: string) => {
const firstLine = value.indexOf('\n');
const key = value.substring(0, firstLine);
acc[key] = value.substring(firstLine + 1).trim();
return acc;
}, {});
const tags = fields.tags
.split(',')
.map((v: string) => v.trim())
.filter((v: string) => v.startsWith('tags: '))
.map((v: string) => v.substring(5).trim());
return new CommitInfo(
fields.hash,
fields.subject,
fields.body,
fields.author,
fields.authorEmail,
new Date(fields.authorDate),
fields.committer,
fields.committerEmail,
new Date(fields.committerDate),
tags
);
});
// check for changes
let changed = true;
if (this.changePath !== '') {
if (startHash === '') {
const changedFiles = await cmd(`git log --name-only --oneline ${endHash} -- ${this.changePath}`);
changed = changedFiles.length > 0;
} else {
const changedFiles = await cmd(`git diff --name-only ${startHash}..${endHash} -- ${this.changePath}`);
changed = changedFiles.length > 0;
}
}
return new CommitInfoSet(changed, commits);
}
}

View file

@ -0,0 +1,25 @@
import { ActionConfig } from "../ActionConfig";
import { cmd } from "../CommandRunner";
import { CurrentCommitResolver } from "./CurrentCommitResolver";
export class DefaultCurrentCommitResolver implements CurrentCommitResolver {
private branch: string;
constructor(config: ActionConfig) {
this.branch = config.branch;
}
public async ResolveAsync(): Promise<string> {
if (this.branch === 'HEAD') {
return (await cmd('git', 'rev-parse', 'HEAD')).trim();
}
return this.branch;
}
public async IsEmptyRepoAsync(): Promise<boolean> {
let lastCommitAll = (await cmd('git', 'rev-list', '-n1', '--all')).trim();
return lastCommitAll === '';
}
}

View file

@ -0,0 +1,23 @@
import { ActionConfig } from "../ActionConfig";
import { CommitInfo } from "./CommitInfo";
import { CommitInfoSet } from "./CommitInfoSet";
import { DefaultVersionClassifier } from "./DefaultVersionClassifier";
import { ReleaseInformation } from "./ReleaseInformation";
test('Regular expressions can be used as minor tag direct', async () => {
const classifier = new DefaultVersionClassifier({ ...new ActionConfig(), ...{ tagPrefix: '', minorPattern: '/S[a-z]+Value/' }});
const releaseInfo =new ReleaseInformation(0,0,1,"",null,null,null);
const commitSet = new CommitInfoSet(false, [
new CommitInfo("", "Second Commit SomeValue", "", "","", new Date(), "", "", new Date(), []),
new CommitInfo("", "Initial Commit", "", "","", new Date(), "", "", new Date(), []),
]);
const result = await classifier.ClassifyAsync(releaseInfo, commitSet);
expect(result.major).toBe(0);
expect(result.minor).toBe(1);
expect(result.patch).toBe(0);
expect(result.increment).toBe(0);
});

View file

@ -0,0 +1,99 @@
import { ActionConfig } from "../ActionConfig";
import { CommitInfo } from "./CommitInfo";
import { CommitInfoSet } from "./CommitInfoSet";
import { ReleaseInformation } from "./ReleaseInformation";
import { VersionClassification } from "./VersionClassification";
import { VersionClassifier } from "./VersionClassifier";
import { VersionType } from "./VersionType";
export class DefaultVersionClassifier implements VersionClassifier {
protected majorPattern: (commit: CommitInfo) => boolean;
protected minorPattern: (commit: CommitInfo) => boolean;
constructor(config: ActionConfig) {
const searchBody = config.searchCommitBody;
this.majorPattern = this.parsePattern(config.majorPattern, searchBody);
this.minorPattern = this.parsePattern(config.minorPattern, searchBody);
}
protected parsePattern(pattern: string, searchBody: boolean): (pattern: CommitInfo) => boolean {
if (pattern.startsWith('/') && pattern.endsWith('/')) {
var regex = new RegExp(pattern.slice(1, -1));
return searchBody ?
(commit: CommitInfo) => regex.test(commit.subject) || regex.test(commit.body) :
(commit: CommitInfo) => regex.test(commit.subject);
} else {
const matchString = pattern;
return searchBody ?
(commit: CommitInfo) => commit.subject.includes(matchString) || commit.body.includes(matchString) :
(commit: CommitInfo) => commit.subject.includes(matchString);
}
}
protected getNextVersion(current: ReleaseInformation, type: VersionType): ({ major: number, minor: number, patch: number }) {
switch (type) {
case VersionType.Major:
return { major: current.major + 1, minor: 0, patch: 0 };
case VersionType.Minor:
return { major: current.major, minor: current.minor + 1, patch: 0 };
case VersionType.Patch:
return { major: current.major, minor: current.minor, patch: current.patch + 1 };
case VersionType.None:
return { major: current.major, minor: current.minor, patch: current.patch };
default:
throw new Error(`Unknown change type: ${type}`);
}
}
private resolveCommitType(commitsSet: CommitInfoSet): ({ type: VersionType, increment: number, changed: boolean }) {
if (commitsSet.commits.length === 0) {
return { type: VersionType.None, increment: 0, changed: commitsSet.changed };
}
const commits = commitsSet.commits.reverse();
let index = 1;
for (let commit of commits) {
if (this.majorPattern(commit)) {
return { type: VersionType.Major, increment: commits.length - index, changed: commitsSet.changed };
}
index++;
}
index = 1;
for (let commit of commits) {
if (this.minorPattern(commit)) {
return { type: VersionType.Minor, increment: commits.length - index, changed: commitsSet.changed };
}
index++;
}
return { type: VersionType.Patch, increment: commitsSet.commits.length - 1, changed: true };
}
public async ClassifyAsync(lastRelease: ReleaseInformation, commitSet: CommitInfoSet): Promise<VersionClassification> {
const { type, increment, changed } = this.resolveCommitType(commitSet);
const { major, minor, patch } = this.getNextVersion(lastRelease, type);
if (lastRelease.currentPatch !== null) {
// If the current commit is tagged, we must use that version. Here we check if the version we have resolved from the
// previous commits is the same as the current version. If it is, we will use the increment value, otherwise we reset
// to zero. For example:
// - commit 1 - v1.0.0+0
// - commit 2 - v1.0.0+1
// - commit 3 was tagged v2.0.0 - v2.0.0+0
// - commit 4 - v2.0.1+0
const versionsMatch = lastRelease.currentMajor === major && lastRelease.currentMinor === minor && lastRelease.currentPatch === patch;
const currentIncremement = versionsMatch ? increment : 0;
return new VersionClassification(VersionType.None, currentIncremement, false, <number>lastRelease.currentMajor, <number>lastRelease.currentMinor, <number>lastRelease.currentPatch);
}
return new VersionClassification(type, increment, changed, major, minor, patch);
}
}

View file

@ -0,0 +1,6 @@
import { TagFormatter } from '../formatting/TagFormatter';
import { ReleaseInformation } from './ReleaseInformation';
export interface LastReleaseResolver {
ResolveAsync(current: string, tagFormatter: TagFormatter): Promise<ReleaseInformation>;
}

View file

@ -0,0 +1,21 @@
// Finds the hash of the last commit
export class ReleaseInformation {
/**
* Creates a new instance
* @param major - the major version number
* @param minor - the minor version number
* @param patch - the patch version number
* @param hash - the hash of commit of the last release
* @param currentMajor - the major version number from the current commit
* @param currentMinor - the minor version number from the current commit
* @param currentPatch - the patch version number from the current commit
*/
constructor(
public major: number,
public minor: number,
public patch: number,
public hash: string,
public currentMajor: number | null,
public currentMinor: number | null,
public currentPatch: number | null,) { }
}

View file

@ -0,0 +1,59 @@
import { cmd } from "../CommandRunner";
import { TagFormatter } from "../formatting/TagFormatter";
import { LastReleaseResolver } from "./LastReleaseResolver";
import { ReleaseInformation } from "./ReleaseInformation";
import { ActionConfig } from "../ActionConfig";
import * as core from '@actions/core';
export class TagLastReleaseResolver implements LastReleaseResolver {
private changePath: string;
constructor(config: ActionConfig) {
this.changePath = config.changePath;
}
async ResolveAsync(current: string, tagFormatter: TagFormatter): Promise<ReleaseInformation> {
const releasePattern = tagFormatter.GetPattern();
let currentTag = (await cmd(
`git tag --points-at ${current} ${releasePattern}`
)).trim();
const [currentMajor, currentMinor, currentPatch] = !!currentTag ? tagFormatter.Parse(currentTag) : [null, null, null];
let tag = '';
try {
tag = (await cmd(
'git',
`describe`,
`--tags`,
`--abbrev=0`,
`--match=${releasePattern}`,
`${current}~1`
)).trim();
}
catch (err) {
tag = '';
}
if (tag === '') {
if (await cmd('git', 'remote') !== '') {
// Since there is no remote, we assume that there are no other tags to pull. In
// practice this isn't likely to happen, but it keeps the test output from being
// polluted with a bunch of warnings.
core.warning('No tags are present for this repository. If this is unexpected, check to ensure that tags have been pulled from the remote.');
}
// no release tags yet, use the initial commit as the root
return new ReleaseInformation(0, 0, 0, '', currentMajor, currentMinor, currentPatch);
}
// parse the version tag
const [major, minor, patch] = tagFormatter.Parse(tag);
const root = await cmd('git', `merge-base`, tag, current);
return new ReleaseInformation(major, minor, patch, root.trim(), currentMajor, currentMinor, currentPatch);
}
}

14
src/providers/UserInfo.ts Normal file
View file

@ -0,0 +1,14 @@
/** Represents information about a user (e.g. committer, author, tagger) */
export class UserInfo {
/**
* Creates a new instance
* @param name - User's name
* @param email - User's email
* @param commits - Number of commits in the scope evaluated
*/
constructor(
public name: string,
public email: string,
public commits: number) { }
}

View file

@ -0,0 +1,21 @@
import { VersionType } from './VersionType';
/** The result of a version classification */
export class VersionClassification {
/**
* Creates a new version classification result instance
* @param type - The type of change the current range represents
* @param increment - The number of commits which have this version, usually zero-based
* @param changed - True if the version has changed, false otherwise
* @param major - The major version number
* @param minor - The minor version number
* @param patch - The patch version number
*/
constructor(
public type: VersionType,
public increment: number,
public changed: boolean,
public major: number,
public minor: number,
public patch: number) { }
}

View file

@ -0,0 +1,17 @@
import { CommitInfoSet } from "./CommitInfoSet";
import { ReleaseInformation } from "./ReleaseInformation";
import { VersionClassification } from "./VersionClassification";
/**
* Defines the 'business logic' to turn commits into parameters for creating
* output values
*/
export interface VersionClassifier {
/**
* Produces the version classification for a given commit set
* @param lastRelease - The last release information
* @param commitSet - The commits to classify, ordered from most recent to oldest
* @returns - The version classification
*/
ClassifyAsync(lastRelease: ReleaseInformation, commitSet: CommitInfoSet): Promise<VersionClassification>;
}

View file

@ -0,0 +1,27 @@
import { CommitInfo } from "./CommitInfo";
import { VersionType } from "./VersionType";
/**
* Represents the "resolved" information about a version change, serves
* as the input to formatters that produce the final output
*/
export class VersionInformation {
/**
* Creates a new version information instance
* @param major - The major version number
* @param minor - The minor version number
* @param patch - The patch version number
* @param increment - The number of commits for this version
* @param type - The type of change the current range represents
* @param commits - The list of commits for this version
* @param changed - True if the version has changed, false otherwise
*/
constructor(
public major: number,
public minor: number,
public patch: number,
public increment: number,
public type: VersionType,
public commits: CommitInfo[],
public changed: boolean) { }
}

View file

@ -0,0 +1,12 @@
/** Indicates the type of change a particular version change represents */
export enum VersionType {
/** Indicates a major version change */
Major,
/** Indicates a minor version change */
Minor,
/** Indicates a patch version change */
Patch,
/** Indicates no change--generally this means that the current commit is already tagged with a version */
None
}