diff --git a/src/execWith.mjs b/src/execWith.mjs new file mode 100644 index 0000000..74f6156 --- /dev/null +++ b/src/execWith.mjs @@ -0,0 +1,10 @@ +export const execWith = execFunction => onError => command => { + try { + return execFunction(command, { + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }).replace(/\n$/, "") + } catch (e) { + return onError(e) + } +} diff --git a/src/index.mjs b/src/index.mjs index e69de29..9f71262 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -0,0 +1,41 @@ +import { execSync } from "child_process" +import { execWith } from "./execWith.mjs" +import { ExtendPipe } from "./Pipe.mjs" +import { GetLatestVersion } from "./pipes/GetLatestVersion.mjs" +import { MakeNewVersion } from "./pipes/MakeNewVersion.mjs" +import { ExitIfNoVersion } from "./pipes/ExitIfNoVersion.mjs" +import { GetVersionCommit } from "./pipes/GetVersionCommit.mjs" +import { GetChanges } from "./pipes/GetChanges.mjs" +import { ForceBump } from "./pipes/ForceBump.mjs" +import { MakeChangelog } from "./pipes/MakeChangelog.mjs" +import { Log } from "./pipes/Log.mjs" + +const argv = process.argv.slice(2) + +const conventions = { + patch: "^:bug: ", + minor: "^:sparkles: ", + major: "^:boom: ", +} + +const execOrElse = execWith(execSync) +const execOrExit = execOrElse(e => { + console.error(e.message || e) + process.exit(1) +}) + +const stdOut = str => process.stdout.write(str) + +const MakeChangelogIfRequired = argv.includes("--changelog") + ? MakeChangelog + : ExtendPipe.empty() + +GetLatestVersion(execOrElse(() => "0.0.0")) + .concat(GetVersionCommit(execOrExit)) + .concat(GetChanges(execOrExit)) + .concat(ForceBump) + .concat(MakeNewVersion) + .concat(ExitIfNoVersion(() => process.exit(0))) + .concat(MakeChangelogIfRequired) + .concat(Log(stdOut)) + .run({ argv, conventions }) diff --git a/src/pipe.mjs b/src/pipe.mjs new file mode 100644 index 0000000..7615d76 --- /dev/null +++ b/src/pipe.mjs @@ -0,0 +1,15 @@ +const _fs = Symbol() + +const extend = f => obj => ({ ...obj, ...f(obj) }) + +export const Pipe = (...fs) => ({ + [_fs]: fs, + concat: o => Pipe(...fs.concat(o[_fs])), + run: initialContext => fs.reduce((acc, f) => f(acc), initialContext), +}) + +Pipe.empty = () => Pipe(x => x) + +export const ExtendPipe = (...fs) => Pipe(...fs.map(extend)) + +ExtendPipe.empty = () => ExtendPipe(() => ({})) diff --git a/src/pipes/ExitIfNoVersion.mjs b/src/pipes/ExitIfNoVersion.mjs new file mode 100644 index 0000000..56537e8 --- /dev/null +++ b/src/pipes/ExitIfNoVersion.mjs @@ -0,0 +1,6 @@ +import { ExtendPipe } from "../Pipe.mjs" + +export const ExitIfNoVersion = onNoNewVersion => + ExtendPipe(({ newVersion, latestVersion }) => + newVersion == latestVersion ? onNoNewVersion() : {}, + ) diff --git a/src/pipes/ForceBump.mjs b/src/pipes/ForceBump.mjs new file mode 100644 index 0000000..1a7abc3 --- /dev/null +++ b/src/pipes/ForceBump.mjs @@ -0,0 +1,14 @@ +import { ExtendPipe } from "../Pipe.mjs" +import { testConvention } from "../utils.mjs" + +export const ForceBump = ExtendPipe( + ({ changes, argv, conventions }) => ({ + argv: Object.keys(conventions).reduce( + (acc, c) => + changes.find(testConvention(conventions[c])) + ? acc.concat([`--${c}`]) + : acc, + argv, + ), + }), +) diff --git a/src/pipes/GetChanges.mjs b/src/pipes/GetChanges.mjs new file mode 100644 index 0000000..e8f4bbb --- /dev/null +++ b/src/pipes/GetChanges.mjs @@ -0,0 +1,23 @@ +import { ExtendPipe } from "../Pipe.mjs" + +/** + * git-rev-list - Lists commit objects in reverse chronological order. + * @see https://git-scm.com/docs/git-rev-list + * + * * --oneline + * This is a shorthand for "--pretty=oneline --abbrev-commit". + * + * * --abbrev-commit + * Instead of showing the full 40-byte hexadecimal commit object name, + * show only a partial prefix. + * + * * --pretty=oneline + * Pretty-print the contents of the commit logs in one line. + */ +export const GetChanges = exec => + ExtendPipe(({ latestVersionCommit }) => ({ + changes: exec(`git rev-list ${latestVersionCommit}..HEAD --oneline`) + .split("\n") + .reverse() + .map(change => change.slice(change.indexOf(" ") + 1)), + })) diff --git a/src/pipes/GetLatestVersion.mjs b/src/pipes/GetLatestVersion.mjs new file mode 100644 index 0000000..72099c4 --- /dev/null +++ b/src/pipes/GetLatestVersion.mjs @@ -0,0 +1,33 @@ +import { ExtendPipe } from "../Pipe.mjs" + +/** + * git-describe - Give an object a human readable name based on an + * available ref. + * @see https://git-scm.com/docs/git-describe + * + * * --tags + * Instead of using only the annotated tags, use any tag found in + * refs/tags namespace. This option enables matching a lightweight + * (non-annotated) tag. + * + * * --abbrev= + * Instead of using the default 7 hexadecimal digits as the abbreviated + * object name, use digits, or as many digits as needed to form a + * unique object name. An of 0 will suppress long format, only + * showing the closest tag. + * + * * --match + * Only consider tags matching the given glob(7) pattern, excluding the + * "refs/tags/" prefix. + * + * @warning The glob "*[0-9].*[0-9].*[0-9]" is not the perfect solution + * as it equally matches "1.0.0" and "1.foo0.bar0". It is used as a + * quick example here and real-life implementation should not rely on + * it. + */ +export const GetLatestVersion = exec => + ExtendPipe(() => ({ + latestVersion: exec( + `git describe --match "*[0-9].*[0-9].*[0-9]" --abbrev=0 HEAD --tags`, + ), + })) diff --git a/src/pipes/GetVersionCommit.mjs b/src/pipes/GetVersionCommit.mjs new file mode 100644 index 0000000..52bc45c --- /dev/null +++ b/src/pipes/GetVersionCommit.mjs @@ -0,0 +1,32 @@ +import { ExtendPipe } from "../Pipe.mjs" + +/** + * git-rev-list - Lists commit objects in reverse chronological order. + * @see https://git-scm.com/docs/git-rev-list + * + * * --max-parents=0 + * Show only commits which have at least (or at most) that many parent + * commits. In particular, --max-parents=0 gives all root commits. + * + * @warning If your repository contains multiple unrelated histories + * merged together, the `git rev-list --max-parents=0 HEAD` command + * will return you more than one commit. + * + * ==================================================================== + * + * git-show-ref - List references in a local repository + * @see https://git-scm.com/docs/git-show-ref. + * + * * --hash + * Only show the SHA-1 hash, not the reference name. + */ +export const GetVersionCommit = ( + execOnNoTags, + execOnTags = execOnNoTags, +) => + ExtendPipe(({ latestVersion }) => ({ + latestVersionCommit: + latestVersion == "0.0.0" + ? execOnNoTags("git rev-list --max-parents=0 HEAD") + : execOnTags(`git show-ref ${latestVersion} -s`), + })) diff --git a/src/pipes/Log.mjs b/src/pipes/Log.mjs new file mode 100644 index 0000000..7ff8fba --- /dev/null +++ b/src/pipes/Log.mjs @@ -0,0 +1,6 @@ +import { ExtendPipe } from "../Pipe.mjs" + +export const Log = logger => + ExtendPipe(({ newVersion, changelog }) => + logger(changelog || newVersion), + ) diff --git a/src/pipes/MakeChangelog.mjs b/src/pipes/MakeChangelog.mjs new file mode 100644 index 0000000..9b0e74b --- /dev/null +++ b/src/pipes/MakeChangelog.mjs @@ -0,0 +1,38 @@ +import { ExtendPipe } from "../Pipe.mjs" +import { testConvention } from "../utils.mjs" + +const trimType = convention => change => + change.replace(new RegExp(convention), "") + +const getMatchingChanges = (convention, changes) => + changes.filter(testConvention(convention)).map(trimType(convention)) + +const writeToChangelog = (title, items) => + items.length + ? `\n## ${title}\n\n${items + .map(change => `* ${change}\n`) + .join("")}` + : "" + +const changelogTag = (_, version, breaks, features, fixes) => + `# ${version}\n` + .concat(writeToChangelog("Breaking Changes", breaks)) + .concat(writeToChangelog("Features & Deprecations", features)) + .concat(writeToChangelog("Bug Fixes", fixes)) + +export const MakeChangelog = ExtendPipe( + ({ changes, newVersion, conventions }) => ({ + changelog: changelogTag` + # ${newVersion} + + ## Breaking Changes + ${getMatchingChanges(conventions.major, changes)} + + ## Features & Deprecations + ${getMatchingChanges(conventions.minor, changes)} + + ## Bug Fixes + ${getMatchingChanges(conventions.patch, changes)} +`, + }), +) diff --git a/src/pipes/MakeNewVersion.mjs b/src/pipes/MakeNewVersion.mjs new file mode 100644 index 0000000..9394ac0 --- /dev/null +++ b/src/pipes/MakeNewVersion.mjs @@ -0,0 +1,21 @@ +import { ExtendPipe } from "../Pipe.mjs" + +const splitVersionToNumbers = ({ latestVersion }) => ({ + newVersion: latestVersion.split(".").map(Number), +}) + +const joinVersionNumbers = ({ newVersion }) => ({ + newVersion: newVersion.join("."), +}) + +const bump = (option, bumpFunc) => ({ newVersion, argv }) => ({ + newVersion: argv.includes(option) ? bumpFunc(newVersion) : newVersion, +}) + +export const MakeNewVersion = ExtendPipe( + splitVersionToNumbers, + bump("--patch", tuple => [tuple[0], tuple[1], tuple[2] + 1]), + bump("--minor", tuple => [tuple[0], tuple[1] + 1, 0]), + bump("--major", tuple => [tuple[0] + 1, 0, 0]), + joinVersionNumbers, +) diff --git a/src/utils.mjs b/src/utils.mjs new file mode 100644 index 0000000..662c65b --- /dev/null +++ b/src/utils.mjs @@ -0,0 +1,2 @@ +export const testConvention = convention => change => + new RegExp(convention).test(change) diff --git a/tests/0_2_Pipe.spec.mjs b/tests/0_2_Pipe.spec.mjs index 9fb7dc3..e3e9513 100644 --- a/tests/0_2_Pipe.spec.mjs +++ b/tests/0_2_Pipe.spec.mjs @@ -1,4 +1,4 @@ -import { ExtendPipe, Pipe } from "../src/pipe.mjs" +import { ExtendPipe, Pipe } from "../src/Pipe.mjs" import { describe, expect, it } from "./clown.mjs" describe("Pipe", () => { diff --git a/tests/index.mjs b/tests/index.mjs index 0d40266..7b621ac 100644 --- a/tests/index.mjs +++ b/tests/index.mjs @@ -1,11 +1,11 @@ import "./clown.spec.mjs" -// import "./0_1_execWith.spec.mjs" -// import "./0_2_Pipe.spec.mjs" -// import "./1_GetLatestVersion.spec.mjs" -// import "./2_MakeNewVersion.spec.mjs" -// import "./3_ExitIfNoVersion.spec.mjs" -// import "./4_GetVersionCommit.spec.mjs" -// import "./5_GetChanges.spec.mjs" -// import "./6_ForceBump.spec.mjs" -// import "./7_MakeChangelog.spec.mjs" -// import "./8_Log.spec.mjs" +import "./0_1_execWith.spec.mjs" +import "./0_2_Pipe.spec.mjs" +import "./1_GetLatestVersion.spec.mjs" +import "./2_MakeNewVersion.spec.mjs" +import "./3_ExitIfNoVersion.spec.mjs" +import "./4_GetVersionCommit.spec.mjs" +import "./5_GetChanges.spec.mjs" +import "./6_ForceBump.spec.mjs" +import "./7_MakeChangelog.spec.mjs" +import "./8_Log.spec.mjs"