mirror of
https://github.com/PaulHatch/semantic-version.git
synced 2026-04-10 01:54:18 +00:00
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:
parent
dc8f0c5755
commit
31f4e3fdf0
74 changed files with 9422 additions and 4342 deletions
23
src/ActionConfig.ts
Normal file
23
src/ActionConfig.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/** Represents the input configuration for the semantic-version action */
|
||||
export class ActionConfig {
|
||||
/** Set to specify a specific branch, default is the current HEAD */
|
||||
public branch: string = "HEAD";
|
||||
/** The prefix to use to identify tags */
|
||||
public tagPrefix: string = "v";
|
||||
/** A string which, if present in a git commit, indicates that a change represents a major (breaking) change. Wrap with '/' to match using a regular expression. */
|
||||
public majorPattern: string = "(MAJOR)";
|
||||
/** A string which, if present in a git commit, indicates that a change represents a minor (feature) change. Wrap with '/' to match using a regular expression. */
|
||||
public minorPattern: string = "(MINOR)";
|
||||
/** Pattern to use when formatting output version */
|
||||
public versionFormat: string = '${major}.${minor}.${patch}';
|
||||
/** Path to check for changes. If any changes are detected in the path the 'changed' output will true. Enter multiple paths separated by spaces. */
|
||||
public changePath: string = '';
|
||||
/** Use to create a named sub-version. This value will be appended to tags created for this version. */
|
||||
public namespace: string = "";
|
||||
/** If true, every commit will be treated as a bump to the version. */
|
||||
public bumpEachCommit: boolean = false;
|
||||
/** If true, the body of commits will also be searched for major/minor patterns to determine the version type */
|
||||
public searchCommitBody: boolean = false;
|
||||
/** The output method used to generate list of users, 'csv' or 'json'. Default is 'csv'. */
|
||||
public userFormatType: string = "csv";
|
||||
}
|
||||
28
src/CommandRunner.ts
Normal file
28
src/CommandRunner.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Using require instead of import to support integration testing
|
||||
import * as exec from '@actions/exec';
|
||||
import * as core from '@actions/core';
|
||||
|
||||
export const cmd = async (command: string, ...args: any): Promise<string> => {
|
||||
let output = '', errors = '';
|
||||
const options = {
|
||||
silent: true,
|
||||
listeners: {
|
||||
stdout: (data: any) => { output += data.toString(); },
|
||||
stderr: (data: any) => { errors += data.toString(); },
|
||||
ignoreReturnCode: true,
|
||||
silent: true
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await exec.exec(command, args, options);
|
||||
} catch (err) {
|
||||
//core.info(`The command cd '${command} ${args.join(' ')}' failed: ${err}`);
|
||||
}
|
||||
|
||||
if (errors !== '') {
|
||||
//core.info(`stderr: ${errors}`);
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
52
src/ConfigurationProvider.ts
Normal file
52
src/ConfigurationProvider.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { CsvUserFormatter } from './formatting/CsvUserFormatter'
|
||||
import { DefaultTagFormatter } from './formatting/DefaultTagFormatter'
|
||||
import { DefaultVersionFormatter } from './formatting/DefaultVersionFormatter'
|
||||
import { JsonUserFormatter } from './formatting/JsonUserFormatter'
|
||||
import { TagFormatter } from './formatting/TagFormatter'
|
||||
import { UserFormatter } from './formatting/UserFormatter'
|
||||
import { VersionFormatter } from './formatting/VersionFormatter'
|
||||
import { CommitsProvider } from './providers/CommitsProvider'
|
||||
import { CurrentCommitResolver } from './providers/CurrentCommitResolver'
|
||||
import { DefaultCommitsProvider } from './providers/DefaultCommitsProvider'
|
||||
import { DefaultCurrentCommitResolver } from './providers/DefaultCurrentCommitResolver'
|
||||
import { DefaultVersionClassifier } from './providers/DefaultVersionClassifier'
|
||||
import { LastReleaseResolver } from './providers/LastReleaseResolver'
|
||||
import { TagLastReleaseResolver } from './providers/TagLastReleaseResolver'
|
||||
import { VersionClassifier } from './providers/VersionClassifier'
|
||||
import { BumpAlwaysVersionClassifier } from './providers/BumpAlwaysVersionClassifier'
|
||||
import { ActionConfig } from './ActionConfig';
|
||||
|
||||
export class ConfigurationProvider {
|
||||
|
||||
constructor(config: ActionConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private config: ActionConfig;
|
||||
|
||||
public GetCurrentCommitResolver(): CurrentCommitResolver { return new DefaultCurrentCommitResolver(this.config); }
|
||||
|
||||
public GetLastReleaseResolver(): LastReleaseResolver { return new TagLastReleaseResolver(this.config); }
|
||||
|
||||
public GetCommitsProvider(): CommitsProvider { return new DefaultCommitsProvider(this.config); }
|
||||
|
||||
public GetVersionClassifier(): VersionClassifier {
|
||||
if (this.config.bumpEachCommit) {
|
||||
return new BumpAlwaysVersionClassifier(this.config);
|
||||
}
|
||||
return new DefaultVersionClassifier(this.config);
|
||||
}
|
||||
|
||||
public GetVersionFormatter(): VersionFormatter { return new DefaultVersionFormatter(this.config); }
|
||||
|
||||
public GetTagFormatter(): TagFormatter { return new DefaultTagFormatter(this.config); }
|
||||
|
||||
public GetUserFormatter(): UserFormatter {
|
||||
switch (this.config.userFormatType) {
|
||||
case 'json': return new JsonUserFormatter(this.config);
|
||||
case 'csv': return new CsvUserFormatter(this.config);
|
||||
default:
|
||||
throw new Error(`Unknown user format type: ${this.config.userFormatType}, supported types: json, csv`);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/VersionResult.ts
Normal file
27
src/VersionResult.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { UserInfo } from "./providers/UserInfo";
|
||||
|
||||
/** Represents the total output for the action */
|
||||
export class VersionResult {
|
||||
/**
|
||||
* Creates a new result 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 (usually used to create version suffix)
|
||||
* @param formattedVersion - The formatted semantic version
|
||||
* @param versionTag - The string to be used as a Git tag
|
||||
* @param changed - True if the version was changed, otherwise false
|
||||
* @param authors - Authors formatted according to the format mode (e.g. JSON, CSV, YAML, etc.)
|
||||
* @param currentCommit - The current commit hash
|
||||
*/
|
||||
constructor(
|
||||
public major: number,
|
||||
public minor: number,
|
||||
public patch: number,
|
||||
public increment: number,
|
||||
public formattedVersion: string,
|
||||
public versionTag: string,
|
||||
public changed: boolean,
|
||||
public authors: string,
|
||||
public currentCommit: string) { }
|
||||
}
|
||||
67
src/action.ts
Normal file
67
src/action.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { ConfigurationProvider } from './ConfigurationProvider';
|
||||
import { VersionResult } from './VersionResult';
|
||||
import { VersionType } from './providers/VersionType';
|
||||
import { UserInfo } from './providers/UserInfo';
|
||||
import { VersionInformation } from './providers/VersionInformation';
|
||||
|
||||
export async function runAction(configurationProvider: ConfigurationProvider): Promise<VersionResult> {
|
||||
|
||||
const currentCommitResolver = configurationProvider.GetCurrentCommitResolver();
|
||||
const lastReleaseResolver = configurationProvider.GetLastReleaseResolver();
|
||||
const commitsProvider = configurationProvider.GetCommitsProvider();
|
||||
const versionClassifier = configurationProvider.GetVersionClassifier();
|
||||
const versionFormatter = configurationProvider.GetVersionFormatter();
|
||||
const tagFormmater = configurationProvider.GetTagFormatter();
|
||||
const userFormatter = configurationProvider.GetUserFormatter();
|
||||
|
||||
if (await currentCommitResolver.IsEmptyRepoAsync()) {
|
||||
let versionInfo = new VersionInformation(0, 0, 0, 0, VersionType.None, [], false);
|
||||
return new VersionResult(
|
||||
versionInfo.major,
|
||||
versionInfo.minor,
|
||||
versionInfo.patch,
|
||||
versionInfo.increment,
|
||||
versionFormatter.Format(versionInfo),
|
||||
tagFormmater.Format(versionInfo),
|
||||
versionInfo.changed,
|
||||
userFormatter.Format('author', []),
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
const currentCommit = await currentCommitResolver.ResolveAsync();
|
||||
const lastRelease = await lastReleaseResolver.ResolveAsync(currentCommit, tagFormmater);
|
||||
const commitSet = await commitsProvider.GetCommitsAsync(lastRelease.hash, currentCommit);
|
||||
const classification = await versionClassifier.ClassifyAsync(lastRelease, commitSet);
|
||||
|
||||
const { major, minor, patch, increment, type, changed } = classification;
|
||||
|
||||
// At this point all necessary data has been pulled from the database, create
|
||||
// version information to be used by the formatters
|
||||
let versionInfo = new VersionInformation(major, minor, patch, increment, type, commitSet.commits, changed);
|
||||
|
||||
// Group all the authors together, count the number of commits per author
|
||||
const allAuthors = versionInfo.commits
|
||||
.reduce((acc: any, commit) => {
|
||||
const key = `${commit.author} <${commit.authorEmail}>`;
|
||||
acc[key] = acc[key] || { n: commit.author, e: commit.authorEmail, c: 0 };
|
||||
acc[key].c++;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const authors = Object.values(allAuthors)
|
||||
.map((u: any) => new UserInfo(u.n, u.e, u.c))
|
||||
.sort((a: UserInfo, b: UserInfo) => b.commits - a.commits);
|
||||
|
||||
return new VersionResult(
|
||||
versionInfo.major,
|
||||
versionInfo.minor,
|
||||
versionInfo.patch,
|
||||
versionInfo.increment,
|
||||
versionFormatter.Format(versionInfo),
|
||||
tagFormmater.Format(versionInfo),
|
||||
versionInfo.changed,
|
||||
userFormatter.Format('author', authors),
|
||||
currentCommit
|
||||
);
|
||||
}
|
||||
14
src/formatting/CsvUserFormatter.ts
Normal file
14
src/formatting/CsvUserFormatter.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { ActionConfig } from '../ActionConfig';
|
||||
import { UserInfo } from '../providers/UserInfo';
|
||||
import { UserFormatter } from './UserFormatter';
|
||||
|
||||
export class CsvUserFormatter implements UserFormatter {
|
||||
|
||||
constructor(config: ActionConfig) {
|
||||
// placeholder for consistency with other formatters
|
||||
}
|
||||
|
||||
public Format(type: string, users: UserInfo[]): string {
|
||||
return users.map(user => `${user.name} <${user.email}>`).join(', ');
|
||||
}
|
||||
}
|
||||
65
src/formatting/DefaultTagFormatter.ts
Normal file
65
src/formatting/DefaultTagFormatter.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { TagFormatter } from './TagFormatter';
|
||||
import { VersionInformation } from "../providers/VersionInformation";
|
||||
import { ActionConfig } from '../ActionConfig';
|
||||
|
||||
/** Default tag formatter which allows a prefix to be specified */
|
||||
export class DefaultTagFormatter implements TagFormatter {
|
||||
|
||||
private tagPrefix: string;
|
||||
private namespace: string;
|
||||
private namespaceSeperator: string;
|
||||
|
||||
constructor(config: ActionConfig) {
|
||||
this.namespace = config.namespace;
|
||||
this.tagPrefix = config.tagPrefix;
|
||||
this.namespaceSeperator = '-'; // maybe make configurable in the future
|
||||
}
|
||||
|
||||
public Format(versionInfo: VersionInformation): string {
|
||||
const result = `${this.tagPrefix}${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`;
|
||||
|
||||
if (!!this.namespace) {
|
||||
return `${result}${this.namespaceSeperator}${this.namespace}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public GetPattern(): string {
|
||||
|
||||
if (!!this.namespace) {
|
||||
return `${this.tagPrefix}*[0-9].*[0-9].*[0-9]${this.namespaceSeperator}${this.namespace}`;
|
||||
}
|
||||
|
||||
return `${this.tagPrefix}*[0-9].*[0-9].*[0-9]`;
|
||||
}
|
||||
|
||||
public Parse(tag: string): [major: number, minor: number, patch: number] {
|
||||
let stripedTag;
|
||||
if (this.tagPrefix.includes('/') && tag.includes(this.tagPrefix)) {
|
||||
let tagParts = tag
|
||||
.replace(this.tagPrefix, '<--!PREFIX!-->')
|
||||
.split('/');
|
||||
stripedTag = tagParts[tagParts.length - 1]
|
||||
.replace('<--!PREFIX!-->', this.tagPrefix);
|
||||
} else {
|
||||
let tagParts = tag.split('/');
|
||||
stripedTag = tagParts[tagParts.length - 1];
|
||||
}
|
||||
let versionValues = stripedTag
|
||||
.substring(this.tagPrefix.length)
|
||||
.slice(0, this.namespace === '' ? 999 : -(this.namespace.length + 1))
|
||||
.split('.');
|
||||
|
||||
let major = parseInt(versionValues[0]);
|
||||
let minor = versionValues.length > 1 ? parseInt(versionValues[1]) : 0;
|
||||
let patch = versionValues.length > 2 ? parseInt(versionValues[2]) : 0;
|
||||
|
||||
if (isNaN(major) || isNaN(minor) || isNaN(patch)) {
|
||||
throw `Invalid tag ${tag} (${versionValues})`;
|
||||
}
|
||||
|
||||
return [major, minor, patch];
|
||||
};
|
||||
|
||||
}
|
||||
20
src/formatting/DefaultVersionFormatter.ts
Normal file
20
src/formatting/DefaultVersionFormatter.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { VersionFormatter } from './VersionFormatter';
|
||||
import { VersionInformation } from "../providers/VersionInformation";
|
||||
import { ActionConfig } from '../ActionConfig';
|
||||
|
||||
export class DefaultVersionFormatter implements VersionFormatter {
|
||||
|
||||
private formatString: string;
|
||||
|
||||
constructor(config: ActionConfig) {
|
||||
this.formatString = config.versionFormat;
|
||||
}
|
||||
|
||||
public Format(versionInfo: VersionInformation): string {
|
||||
return this.formatString
|
||||
.replace('${major}', versionInfo.major.toString())
|
||||
.replace('${minor}', versionInfo.minor.toString())
|
||||
.replace('${patch}', versionInfo.patch.toString())
|
||||
.replace('${increment}', versionInfo.increment.toString());
|
||||
}
|
||||
}
|
||||
13
src/formatting/JsonUserFormatter.ts
Normal file
13
src/formatting/JsonUserFormatter.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { ActionConfig } from '../ActionConfig';
|
||||
import { UserInfo } from '../providers/UserInfo';
|
||||
import { UserFormatter } from './UserFormatter';
|
||||
|
||||
export class JsonUserFormatter implements UserFormatter {
|
||||
constructor(config: ActionConfig) {
|
||||
// placeholder for consistency with other formatters
|
||||
}
|
||||
public Format(type: string, users: UserInfo[]): string {
|
||||
let result: any = users.map(u => ({ name: u.name, email: u.email }));
|
||||
return JSON.stringify(result).replace('\n', '');
|
||||
}
|
||||
}
|
||||
7
src/formatting/TagFormatter.ts
Normal file
7
src/formatting/TagFormatter.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { VersionInformation } from "../providers/VersionInformation";
|
||||
|
||||
export interface TagFormatter {
|
||||
Format(versionInfo: VersionInformation): string;
|
||||
GetPattern(): string;
|
||||
Parse(tag: string): [major: number, minor: number, patch: number];
|
||||
}
|
||||
6
src/formatting/UserFormatter.ts
Normal file
6
src/formatting/UserFormatter.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { UserInfo } from "../providers/UserInfo";
|
||||
|
||||
/** Formats a list of users as a string (e.g. JSON, YAML, CSV, etc) */
|
||||
export interface UserFormatter {
|
||||
Format(type: string, users: UserInfo[]): string;
|
||||
}
|
||||
6
src/formatting/VersionFormatter.ts
Normal file
6
src/formatting/VersionFormatter.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { VersionInformation } from "../providers/VersionInformation";
|
||||
|
||||
// Formatters
|
||||
export interface VersionFormatter {
|
||||
Format(versionInfo: VersionInformation): string;
|
||||
}
|
||||
466
src/main.test.ts
Normal file
466
src/main.test.ts
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
import * as process from 'process'
|
||||
import * as cp from 'child_process'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os';
|
||||
import { expect, test } from '@jest/globals'
|
||||
import { runAction } from '../src/action';
|
||||
import { ConfigurationProvider } from './ConfigurationProvider';
|
||||
import { ActionConfig } from './ActionConfig';
|
||||
|
||||
const windows = process.platform === "win32";
|
||||
|
||||
// Creates a randomly named git repository and returns a function to execute commands in it
|
||||
const createTestRepo = (repoDefaultConfig?: Partial<ActionConfig>) => {
|
||||
const repoDirectory = path.join(os.tmpdir(), `test${Math.random().toString(36).substring(2, 15)}`);
|
||||
cp.execSync(`mkdir ${repoDirectory}`);
|
||||
cp.execSync(`git init ${repoDirectory}`);
|
||||
|
||||
const run = (command: string) => {
|
||||
return execute(repoDirectory, command);
|
||||
}
|
||||
|
||||
// Configure up git user
|
||||
run(`git config user.name "Test User"`);
|
||||
run(`git config user.email "test@example.com"`);
|
||||
|
||||
let i = 1;
|
||||
|
||||
return {
|
||||
makeCommit: (msg: string, path: string = '') => {
|
||||
if (windows) {
|
||||
run(`fsutil file createnew ${path !== '' ? path.trim() + '/' : ''}test${i++} 0`);
|
||||
} else {
|
||||
run(`touch ${path !== '' ? path.trim() + '/' : ''}test${i++}`);
|
||||
}
|
||||
run(`git add --all`);
|
||||
run(`git commit -m "${msg}"`);
|
||||
},
|
||||
runAction: async (inputs?: Partial<ActionConfig>) => {
|
||||
let config = new ActionConfig();
|
||||
config = { ...config, ...{ versionFormat: "${major}.${minor}.${patch}+${increment}" }, ...repoDefaultConfig, ...inputs };
|
||||
process.chdir(repoDirectory);
|
||||
return await runAction(new ConfigurationProvider(config));
|
||||
},
|
||||
exec: run
|
||||
};
|
||||
};
|
||||
|
||||
// Executes a set of commands in the specified directory
|
||||
const execute = (workingDirectory: string, command: string, env?: any) => {
|
||||
try {
|
||||
return String(cp.execSync(command, { env: { ...process.env, ...env }, cwd: workingDirectory }));
|
||||
}
|
||||
catch (e: any) {
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
test('Empty repository version is correct', async () => {
|
||||
const repo = createTestRepo(); // 0.0.0+0
|
||||
var result = await repo.runAction();
|
||||
|
||||
expect(result.formattedVersion).toBe('0.0.0+0');
|
||||
}, 15000);
|
||||
|
||||
test('Repository with commits shows increment', async () => {
|
||||
const repo = createTestRepo(); // 0.0.0+0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit(`Second Commit`); // 0.0.1+1
|
||||
const result = await repo.runAction();
|
||||
|
||||
expect(result.formattedVersion).toBe('0.0.1+1');
|
||||
}, 15000);
|
||||
|
||||
test('Repository show commit for checked out commit', async () => {
|
||||
const repo = createTestRepo(); // 0.0.0+0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit(`Second Commit`); // 0.0.1+1
|
||||
let result = await repo.runAction();
|
||||
expect(result.formattedVersion).toBe('0.0.1+1');
|
||||
|
||||
repo.exec(`git checkout HEAD~1`); // 0.0.1+1
|
||||
result = await repo.runAction();
|
||||
expect(result.formattedVersion).toBe('0.0.1+0');
|
||||
}, 15000);
|
||||
|
||||
test('Tagging does not break version', async () => {
|
||||
const repo = createTestRepo(); // 0.0.0+0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit(`Second Commit`); // 0.0.1+1
|
||||
repo.makeCommit(`Third Commit`); // 0.0.1+2
|
||||
repo.exec('git tag v0.0.1')
|
||||
const result = await repo.runAction();
|
||||
|
||||
expect(result.formattedVersion).toBe('0.0.1+2');
|
||||
}, 15000);
|
||||
|
||||
test('Minor update bumps minor version and resets increment', async () => {
|
||||
const repo = createTestRepo(); // 0.0.0+0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit('Second Commit (MINOR)'); // 0.1.0+0
|
||||
const result = await repo.runAction();
|
||||
|
||||
expect(result.formattedVersion).toBe('0.1.0+0');
|
||||
}, 15000);
|
||||
|
||||
test('Major update bumps major version and resets increment', async () => {
|
||||
const repo = createTestRepo(); // 0.0.0+0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit('Second Commit (MAJOR)'); // 1.0.0+0
|
||||
const result = await repo.runAction();
|
||||
|
||||
|
||||
expect(result.formattedVersion).toBe('1.0.0+0');
|
||||
}, 15000);
|
||||
|
||||
test('Multiple major commits are idempotent', async () => {
|
||||
const repo = createTestRepo(); // 0.0.0+0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit('Second Commit (MAJOR)'); // 1.0.0+0
|
||||
repo.makeCommit('Third Commit (MAJOR)'); // 1.0.0+1
|
||||
const result = await repo.runAction();
|
||||
|
||||
|
||||
expect(result.formattedVersion).toBe('1.0.0+1');
|
||||
}, 15000);
|
||||
|
||||
test('Minor commits after a major commit are ignored', async () => {
|
||||
const repo = createTestRepo(); // 0.0.0+0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit('Second Commit (MAJOR)'); // 1.0.0+0
|
||||
repo.makeCommit('Third Commit (MINOR)'); // 1.0.0+1
|
||||
const result = await repo.runAction();
|
||||
|
||||
expect(result.formattedVersion).toBe('1.0.0+1');
|
||||
}, 15000);
|
||||
|
||||
test('Tags start new version', async () => {
|
||||
const repo = createTestRepo(); // 0.0.0+0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit('Second Commit'); // 0.0.1+1
|
||||
repo.exec('git tag v0.0.1');
|
||||
repo.makeCommit('Third Commit'); // 0.0.2+0
|
||||
const result = await repo.runAction();
|
||||
|
||||
|
||||
expect(result.formattedVersion).toBe('0.0.2+0');
|
||||
}, 15000);
|
||||
|
||||
test('Version pulled from last release branch', async () => {
|
||||
const repo = createTestRepo(); // 0.0.0+0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.exec('git tag v0.0.1');
|
||||
repo.makeCommit('Second Commit'); // 0.0.2+0
|
||||
repo.exec('git tag v5.6.7');
|
||||
repo.makeCommit('Third Commit'); // 5.6.7+0
|
||||
const result = await repo.runAction();
|
||||
|
||||
|
||||
expect(result.formattedVersion).toBe('5.6.8+0');
|
||||
}, 15000);
|
||||
|
||||
/* Removed for now
|
||||
test('Tags on branches are used', async () => {
|
||||
|
||||
// This test checks that tags are counted correctly even if they are not on
|
||||
// the main branch:
|
||||
// master o--o--o--o <- expecting v0.0.2
|
||||
// \
|
||||
// release o--o <- taged v0.0.1
|
||||
|
||||
|
||||
const repo = createTestRepo(); // 0.0.0+0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit('Second Commit'); // 0.0.1+1
|
||||
repo.makeCommit('Third Commit'); // 0.1.1+2
|
||||
repo.exec('git checkout -b release/0.0.1')
|
||||
repo.makeCommit('Fourth Commit'); // 0.1.1+3
|
||||
repo.exec('git tag v0.0.1');
|
||||
repo.exec('git checkout master');
|
||||
repo.makeCommit('Fifth Commit'); // 0.0.2.0
|
||||
const result = await repo.runAction();
|
||||
|
||||
expect(result.formattedVersion).toBe('0.0.2+0');
|
||||
});
|
||||
*/
|
||||
|
||||
test('Merged tags do not affect version', async () => {
|
||||
|
||||
// This test checks that merges don't override tags
|
||||
|
||||
// Tagged v0.0.2
|
||||
// v
|
||||
// master o--o--o---o---o <- expecting v0.0.3+1
|
||||
// \ /
|
||||
// release o---o <- taged v0.0.1
|
||||
|
||||
|
||||
const repo = createTestRepo(); // 0.0.0+0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit('Second Commit'); // 0.0.1+1
|
||||
repo.makeCommit('Third Commit'); // 0.1.1+2
|
||||
repo.exec('git checkout -b release/0.0.1')
|
||||
repo.makeCommit('Fourth Commit'); // 0.1.1+3
|
||||
repo.exec('git tag v0.0.1');
|
||||
repo.exec('git checkout master');
|
||||
repo.makeCommit('Fifth Commit'); // 0.0.2.0
|
||||
repo.exec('git tag v0.0.2');
|
||||
repo.exec('git merge release/0.0.1');
|
||||
const result = await repo.runAction();
|
||||
|
||||
expect(result.formattedVersion).toBe('0.0.3+1');
|
||||
}, 15000);
|
||||
|
||||
test('Format input is respected', async () => {
|
||||
const repo = createTestRepo({ versionFormat: 'M${major}m${minor}p${patch}i${increment}' }); // M0m0p0i0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // M1m2p3i0
|
||||
repo.exec('git tag v1.2.3');
|
||||
repo.makeCommit(`Second Commit`); // M1m2p4i0
|
||||
const result = await repo.runAction();
|
||||
|
||||
expect(result.formattedVersion).toBe('M1m2p4i0');
|
||||
}, 15000);
|
||||
|
||||
test('Version prefixes are not required/can be empty', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '' }); // 0.0.0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1
|
||||
repo.exec('git tag 0.0.1');
|
||||
repo.makeCommit(`Second Commit`); // 0.0.2
|
||||
const result = await repo.runAction();
|
||||
|
||||
expect(result.formattedVersion).toBe('0.0.2+0');
|
||||
}, 15000);
|
||||
|
||||
test('Tag order comes from commit order, not tag create order', async () => {
|
||||
const repo = createTestRepo(); // 0.0.0+0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit('Second Commit'); // 0.0.1+1
|
||||
repo.makeCommit('Third Commit'); // 0.0.1+2
|
||||
repo.exec('git tag v2.0.0');
|
||||
// Can't timeout in this context on Windows, ping localhost to delay
|
||||
repo.exec(windows ? 'ping 127.0.0.1 -n 2' : 'sleep 2');
|
||||
repo.exec('git tag v1.0.0 HEAD~1');
|
||||
repo.makeCommit('Fourth Commit'); // 0.0.1+2
|
||||
|
||||
const result = await repo.runAction();
|
||||
|
||||
|
||||
expect(result.formattedVersion).toBe('2.0.1+0');
|
||||
}, 15000);
|
||||
|
||||
|
||||
test('Change detection is true by default', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '' }); // 0.0.0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1
|
||||
repo.exec('git tag 0.0.1');
|
||||
repo.makeCommit(`Second Commit`); // 0.0.2
|
||||
const result = await repo.runAction();
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
}, 15000);
|
||||
|
||||
test('Changes to monitored path is true when change is in path', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '' }); // 0.0.0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1
|
||||
repo.exec('git tag 0.0.1');
|
||||
repo.exec('mkdir project1');
|
||||
repo.makeCommit(`Second Commit`, 'project1'); // 0.0.2
|
||||
const result = await repo.runAction({ changePath: "project1" });
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
}, 15000);
|
||||
|
||||
test('Changes to monitored path is false when changes are not in path', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '' }); // 0.0.0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1
|
||||
repo.exec('git tag 0.0.1');
|
||||
repo.exec('mkdir project1');
|
||||
repo.exec('mkdir project2');
|
||||
repo.makeCommit(`Second Commit`, 'project2'); // 0.0.2
|
||||
const result = await repo.runAction({ changePath: "project1" });
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
}, 15000);
|
||||
|
||||
test('Changes can be detected without tags', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '' }); // 0.0.0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1
|
||||
repo.exec('mkdir project1');
|
||||
repo.makeCommit(`Second Commit`, 'project1'); // 0.0.2
|
||||
const result = await repo.runAction({ changePath: "project1" });
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
}, 15000);
|
||||
|
||||
test('Changes to multiple monitored path is true when change is in path', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '' }); // 0.0.0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1
|
||||
repo.exec('git tag 0.0.1');
|
||||
repo.exec('mkdir project1');
|
||||
repo.exec('mkdir project2');
|
||||
repo.makeCommit(`Second Commit`, 'project2'); // 0.0.2
|
||||
const result = await repo.runAction({ changePath: "project1 project2" });
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
}, 15000);
|
||||
|
||||
test('Changes to multiple monitored path is false when change is not in path', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '' }); // 0.0.0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1
|
||||
repo.exec('git tag 0.0.1');
|
||||
repo.exec('mkdir project1');
|
||||
repo.exec('mkdir project2');
|
||||
repo.exec('mkdir project3');
|
||||
repo.makeCommit(`Second Commit`, 'project3'); // 0.0.2
|
||||
const result = await repo.runAction({ changePath: "project1 project2" });
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
}, 15000);
|
||||
|
||||
test('Namespace is tracked separately', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '' }); // 0.0.0
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1
|
||||
repo.exec('git tag 0.0.1');
|
||||
repo.makeCommit('Second Commit'); // 0.0.2
|
||||
repo.exec('git tag 0.1.0-subproject');
|
||||
repo.makeCommit('Third Commit'); // 0.0.2 / 0.1.1
|
||||
|
||||
const result = await repo.runAction();
|
||||
const subprojectResult = await repo.runAction({ namespace: "subproject" });
|
||||
|
||||
expect(result.formattedVersion).toBe('0.0.2+1');
|
||||
expect(subprojectResult.formattedVersion).toBe('0.1.1+0');
|
||||
}, 15000);
|
||||
|
||||
test('Commits outside of path are not counted', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '' }); // 0.0.0
|
||||
|
||||
repo.makeCommit('Initial Commit');
|
||||
repo.makeCommit('Second Commit');
|
||||
repo.makeCommit('Third Commit');
|
||||
|
||||
const result = await repo.runAction({ changePath: "project1" });
|
||||
|
||||
expect(result.formattedVersion).toBe('0.0.0+0');
|
||||
}, 15000);
|
||||
|
||||
test('Commits inside path are counted', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '' }); // 0.0.0
|
||||
|
||||
repo.makeCommit('Initial Commit');
|
||||
repo.makeCommit('Second Commit');
|
||||
repo.makeCommit('Third Commit');
|
||||
repo.exec('mkdir project1');
|
||||
repo.makeCommit('Fourth Commit', 'project1'); // 0.0.1+0
|
||||
repo.makeCommit('Fifth Commit', 'project1'); // 0.0.1+1
|
||||
repo.makeCommit('Sixth Commit', 'project1'); // 0.0.1+2
|
||||
|
||||
const result = await repo.runAction({ changePath: "project1" });
|
||||
|
||||
expect(result.formattedVersion).toBe('0.0.1+2');
|
||||
}, 15000);
|
||||
|
||||
test('Current tag is used', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '' }); // 0.0.0
|
||||
|
||||
repo.makeCommit('Initial Commit');
|
||||
repo.makeCommit('Second Commit');
|
||||
repo.makeCommit('Third Commit');
|
||||
repo.exec('git tag 7.6.5');
|
||||
|
||||
const result = await repo.runAction();
|
||||
|
||||
expect(result.formattedVersion).toBe('7.6.5+0');
|
||||
}, 15000);
|
||||
|
||||
test('Bump each commit works', async () => {
|
||||
|
||||
const repo = createTestRepo({ tagPrefix: '', bumpEachCommit: true }); // 0.0.0
|
||||
|
||||
expect((await repo.runAction()).formattedVersion).toBe('0.0.0+0');
|
||||
repo.makeCommit('Initial Commit');
|
||||
expect((await repo.runAction()).formattedVersion).toBe('0.0.1+0');
|
||||
repo.makeCommit('Second Commit');
|
||||
expect((await repo.runAction()).formattedVersion).toBe('0.0.2+0');
|
||||
repo.makeCommit('Third Commit');
|
||||
expect((await repo.runAction()).formattedVersion).toBe('0.0.3+0');
|
||||
repo.makeCommit('Fourth Commit (MINOR)');
|
||||
expect((await repo.runAction()).formattedVersion).toBe('0.1.0+0');
|
||||
repo.makeCommit('Fifth Commit');
|
||||
expect((await repo.runAction()).formattedVersion).toBe('0.1.1+0');
|
||||
repo.makeCommit('Sixth Commit (MAJOR)');
|
||||
expect((await repo.runAction()).formattedVersion).toBe('1.0.0+0');
|
||||
repo.makeCommit('Seventh Commit');
|
||||
expect((await repo.runAction()).formattedVersion).toBe('1.0.1+0');
|
||||
}, 15000);
|
||||
|
||||
test('Bump each commit picks up tags', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '', bumpEachCommit: true }); // 0.0.0
|
||||
|
||||
expect((await repo.runAction()).formattedVersion).toBe('0.0.0+0');
|
||||
repo.makeCommit('Initial Commit');
|
||||
expect((await repo.runAction()).formattedVersion).toBe('0.0.1+0');
|
||||
repo.makeCommit('Second Commit');
|
||||
expect((await repo.runAction()).formattedVersion).toBe('0.0.2+0');
|
||||
repo.makeCommit('Third Commit');
|
||||
repo.exec('git tag 3.0.0');
|
||||
expect((await repo.runAction()).formattedVersion).toBe('3.0.0+0');
|
||||
repo.makeCommit('Fourth Commit');
|
||||
expect((await repo.runAction()).formattedVersion).toBe('3.0.1+0');
|
||||
}, 15000);
|
||||
|
||||
test('Increment not affected by matching tag', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '' }); // 0.0.1
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit('Second Commit'); // 0.0.1+1
|
||||
repo.exec('git tag 0.0.1');
|
||||
expect((await repo.runAction()).formattedVersion).toBe('0.0.1+1');
|
||||
}, 15000);
|
||||
|
||||
test('Regular expressions can be used as major tag', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '', majorPattern: '/S[a-z]+Value/' }); // 0.0.1
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit('Second Commit SomeValue'); // 1.0.0+0
|
||||
expect((await repo.runAction()).formattedVersion).toBe('1.0.0+0');
|
||||
}, 15000);
|
||||
|
||||
test('Regular expressions can be used as minor tag', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: '', minorPattern: '/S[a-z]+Value/' }); // 0.0.1
|
||||
|
||||
repo.makeCommit('Initial Commit'); // 0.0.1+0
|
||||
repo.makeCommit('Second Commit SomeValue'); // 0.0.1+1
|
||||
expect((await repo.runAction()).formattedVersion).toBe('0.1.0+0');
|
||||
}, 15000);
|
||||
|
||||
test('Tag prefix can include forward slash', async () => {
|
||||
const repo = createTestRepo({ tagPrefix: 'version/' }); // 0.0.0
|
||||
|
||||
repo.makeCommit('Initial Commit');
|
||||
repo.exec('git tag version/1.2.3');
|
||||
const result = await repo.runAction();
|
||||
|
||||
expect(result.formattedVersion).toBe('1.2.3+0');
|
||||
}, 15000);
|
||||
59
src/main.ts
Normal file
59
src/main.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { runAction } from './action';
|
||||
import { ActionConfig } from './ActionConfig';
|
||||
import { ConfigurationProvider } from './ConfigurationProvider';
|
||||
import { VersionResult } from './VersionResult';
|
||||
import * as core from '@actions/core';
|
||||
|
||||
function setOutput(versionResult: VersionResult) {
|
||||
const { major, minor, patch, increment, formattedVersion, versionTag, changed, authors, currentCommit } = versionResult;
|
||||
|
||||
const repository = process.env.GITHUB_REPOSITORY;
|
||||
|
||||
if (!changed) {
|
||||
core.info('No changes detected for this commit');
|
||||
}
|
||||
|
||||
core.info(`Version is ${formattedVersion}`);
|
||||
if (repository !== undefined) {
|
||||
core.info(`To create a release for this version, go to https://github.com/${repository}/releases/new?tag=${versionTag}&target=${currentCommit.split('/').slice(-1)[0]}`);
|
||||
}
|
||||
|
||||
core.setOutput("version", formattedVersion);
|
||||
core.setOutput("major", major.toString());
|
||||
core.setOutput("minor", minor.toString());
|
||||
core.setOutput("patch", patch.toString());
|
||||
core.setOutput("increment", increment.toString());
|
||||
core.setOutput("changed", changed.toString());
|
||||
core.setOutput("version_tag", versionTag);
|
||||
core.setOutput("authors", authors);
|
||||
}
|
||||
|
||||
export async function run() {
|
||||
|
||||
const config: ActionConfig = {
|
||||
branch: core.getInput('branch'),
|
||||
tagPrefix: core.getInput('tag_prefix'),
|
||||
majorPattern: core.getInput('major_pattern'),
|
||||
minorPattern: core.getInput('minor_pattern'),
|
||||
versionFormat: core.getInput('version_format'),
|
||||
changePath: core.getInput('change_path'),
|
||||
namespace: core.getInput('namespace'),
|
||||
bumpEachCommit: core.getInput('bump_each_commit') === 'true',
|
||||
searchCommitBody: core.getInput('search_commit_body') === 'true',
|
||||
userFormatType: core.getInput('user_format_type')
|
||||
};
|
||||
|
||||
if (config.versionFormat === '' && core.getInput('format') !== '') {
|
||||
core.warning(`The 'format' input is deprecated, use 'versionFormat' instead`);
|
||||
config.versionFormat = core.getInput('format');
|
||||
}
|
||||
if (core.getInput('short_tags') !== '') {
|
||||
core.warning(`The 'short_tags' input option is no longer supported`);
|
||||
}
|
||||
|
||||
const configurationProvider = new ConfigurationProvider(config);
|
||||
const result = await runAction(configurationProvider);
|
||||
setOutput(result);
|
||||
}
|
||||
|
||||
run();
|
||||
47
src/providers/BumpAlwaysVersionClassifier.ts
Normal file
47
src/providers/BumpAlwaysVersionClassifier.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
27
src/providers/CommitInfo.ts
Normal file
27
src/providers/CommitInfo.ts
Normal 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[]) { }
|
||||
}
|
||||
9
src/providers/CommitInfoSet.ts
Normal file
9
src/providers/CommitInfoSet.ts
Normal 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[]
|
||||
){}
|
||||
}
|
||||
14
src/providers/CommitsProvider.ts
Normal file
14
src/providers/CommitsProvider.ts
Normal 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>;
|
||||
}
|
||||
13
src/providers/CurrentCommitResolver.ts
Normal file
13
src/providers/CurrentCommitResolver.ts
Normal 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>;
|
||||
}
|
||||
94
src/providers/DefaultCommitsProvider.ts
Normal file
94
src/providers/DefaultCommitsProvider.ts
Normal 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);
|
||||
|
||||
}
|
||||
}
|
||||
25
src/providers/DefaultCurrentCommitResolver.ts
Normal file
25
src/providers/DefaultCurrentCommitResolver.ts
Normal 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 === '';
|
||||
}
|
||||
}
|
||||
23
src/providers/DefaultVersionClassifier.test.ts
Normal file
23
src/providers/DefaultVersionClassifier.test.ts
Normal 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);
|
||||
});
|
||||
99
src/providers/DefaultVersionClassifier.ts
Normal file
99
src/providers/DefaultVersionClassifier.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
6
src/providers/LastReleaseResolver.ts
Normal file
6
src/providers/LastReleaseResolver.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { TagFormatter } from '../formatting/TagFormatter';
|
||||
import { ReleaseInformation } from './ReleaseInformation';
|
||||
|
||||
export interface LastReleaseResolver {
|
||||
ResolveAsync(current: string, tagFormatter: TagFormatter): Promise<ReleaseInformation>;
|
||||
}
|
||||
21
src/providers/ReleaseInformation.ts
Normal file
21
src/providers/ReleaseInformation.ts
Normal 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,) { }
|
||||
}
|
||||
59
src/providers/TagLastReleaseResolver.ts
Normal file
59
src/providers/TagLastReleaseResolver.ts
Normal 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
14
src/providers/UserInfo.ts
Normal 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) { }
|
||||
}
|
||||
21
src/providers/VersionClassification.ts
Normal file
21
src/providers/VersionClassification.ts
Normal 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) { }
|
||||
}
|
||||
17
src/providers/VersionClassifier.ts
Normal file
17
src/providers/VersionClassifier.ts
Normal 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>;
|
||||
}
|
||||
27
src/providers/VersionInformation.ts
Normal file
27
src/providers/VersionInformation.ts
Normal 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) { }
|
||||
}
|
||||
12
src/providers/VersionType.ts
Normal file
12
src/providers/VersionType.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue