Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: inferring the semver version according to Conventional Commit #71

Merged
merged 5 commits into from
Jan 25, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 35 additions & 16 deletions src/get-new-version.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import type { GitCommit } from './git'
import type { BumpRelease, PromptRelease } from './normalize-options'
import type { Operation } from './operation'
import type { ReleaseType } from './release-type'
import process from 'node:process'
import c from 'picocolors'
import prompts from 'prompts'
import semver, { clean as cleanVersion, valid as isValidVersion, SemVer } from 'semver'
import { printRecentCommits } from './print-commits'
import { isPrerelease, releaseTypes } from './release-type'

/**
* Determines the new version number, possibly by prompting the user for it.
*/
export async function getNewVersion(operation: Operation): Promise<Operation> {
export async function getNewVersion(operation: Operation, commits: GitCommit[]): Promise<Operation> {
const { release } = operation.options
const { currentVersion } = operation.state

switch (release.type) {
case 'prompt':
return promptForNewVersion(operation)
return promptForNewVersion(operation, commits)

case 'version':
return operation.update({
Expand All @@ -27,20 +27,27 @@ export async function getNewVersion(operation: Operation): Promise<Operation> {
default:
return operation.update({
release: release.type,
newVersion: getNextVersion(currentVersion, release),
newVersion: getNextVersion(currentVersion, release, commits),
})
}
}

/**
* Returns the next version number of the specified type.
*/
function getNextVersion(currentVersion: string, bump: BumpRelease): string {
function getNextVersion(currentVersion: string, bump: BumpRelease, commits: GitCommit[]): string {
const oldSemVer = new SemVer(currentVersion)

const type = bump.type === 'next'
? oldSemVer.prerelease.length ? 'prerelease' : 'patch'
: bump.type
let type: ReleaseType
if (bump.type === 'next') {
type = oldSemVer.prerelease.length ? 'prerelease' : 'patch'
}
else if (bump.type === 'semver') {
type = oldSemVer.prerelease.length ? 'prerelease' : determineSemverChange(commits)
}
else {
type = bump.type
}

const newSemVer = oldSemVer.inc(type, bump.preid)

Expand All @@ -61,18 +68,32 @@ function getNextVersion(currentVersion: string, bump: BumpRelease): string {
return newSemVer.version
}

function determineSemverChange(commits: GitCommit[]) {
let [hasMajor, hasMinor] = [false, false]
for (const commit of commits) {
if (commit.isBreaking) {
hasMajor = true
}
else if (commit.type === 'feat') {
hasMinor = true
}
}

return hasMajor ? 'major' : hasMinor ? 'minor' : 'patch'
}

/**
* Returns the next version number for all release types.
*/
function getNextVersions(currentVersion: string, preid: string): Record<ReleaseType, string> {
function getNextVersions(currentVersion: string, preid: string, commits: GitCommit[]): Record<ReleaseType, string> {
const next: Record<string, string> = {}

const parse = semver.parse(currentVersion)
if (typeof parse?.prerelease[0] === 'string')
preid = parse?.prerelease[0] || 'preid'

for (const type of releaseTypes)
next[type] = getNextVersion(currentVersion, { type, preid })
next[type] = getNextVersion(currentVersion, { type, preid }, commits)

return next
}
Expand All @@ -82,17 +103,13 @@ function getNextVersions(currentVersion: string, preid: string): Record<ReleaseT
*
* @returns - A tuple containing the new version number and the release type (if any)
*/
async function promptForNewVersion(operation: Operation): Promise<Operation> {
async function promptForNewVersion(operation: Operation, commits: GitCommit[]): Promise<Operation> {
const { currentVersion } = operation.state
const release = operation.options.release as PromptRelease

const next = getNextVersions(currentVersion, release.preid)
const next = getNextVersions(currentVersion, release.preid, commits)
const configCustomVersion = await operation.options.customVersion?.(currentVersion, semver)

if (operation.options.printCommits) {
await printRecentCommits(operation)
}

const PADDING = 13
const answers = await prompts([
{
Expand All @@ -104,6 +121,7 @@ async function promptForNewVersion(operation: Operation): Promise<Operation> {
{ value: 'major', title: `${'major'.padStart(PADDING, ' ')} ${c.bold(next.major)}` },
{ value: 'minor', title: `${'minor'.padStart(PADDING, ' ')} ${c.bold(next.minor)}` },
{ value: 'patch', title: `${'patch'.padStart(PADDING, ' ')} ${c.bold(next.patch)}` },
{ value: 'semver', title: `${'semver'.padStart(PADDING, ' ')} ${c.bold(next.semver)}` },
northword marked this conversation as resolved.
Show resolved Hide resolved
{ value: 'next', title: `${'next'.padStart(PADDING, ' ')} ${c.bold(next.next)}` },
...configCustomVersion
? [
Expand Down Expand Up @@ -146,6 +164,7 @@ async function promptForNewVersion(operation: Operation): Promise<Operation> {
case 'custom':
case 'config':
case 'next':
case 'semver':
case 'none':
return operation.update({ newVersion })

Expand Down
115 changes: 115 additions & 0 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,118 @@ export function formatVersionString(template: string, newVersion: string): strin
else
return template + newVersion
}

export async function getLastGitTag() {
return (await x(
'git',
['describe', '--tags', '--abbrev=0'],
{ nodeOptions: { stdio: 'pipe' }, throwOnError: false },
)).stdout.split('\n').at(0) || undefined
}

export interface GitCommitAuthor {
name: string
email: string
}

export interface RawGitCommit {
message: string
body: string
shortHash: string
author: GitCommitAuthor
}

export interface Reference {
type: 'hash' | 'issue' | 'pull-request'
value: string
}

export interface GitCommit extends Omit<RawGitCommit, 'author'> {
description: string
type: string
scope: string
references: Reference[]
authors: GitCommitAuthor[]
isBreaking: boolean
}

export async function getGitDiff(
from: string | undefined,
to = 'HEAD',
): Promise<string> {
// https://git-scm.com/docs/pretty-formats
const r = await x(
'git',
['--no-pager', 'log', from ? `${from}...${to}` : to, '--pretty="----%n%s|%h|%an|%ae%n%b"'],
)
return r.stdout
}

// https://www.conventionalcommits.org/en/v1.0.0/
// https://regex101.com/r/FSfNvA/1
const ConventionalCommitRegex
= /(?<emoji>:.+:|(\uD83C[\uDF00-\uDFFF])|(\uD83D[\uDC00-\uDE4F\uDE80-\uDEFF])|[\u2600-\u2B55])?( *)(?<type>[a-z]+)(\((?<scope>.+)\))?(?<breaking>!)?: (?<description>.+)/i
// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/no-misleading-capturing-group
const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gi
const PullRequestRE = /\([ a-z]*(#\d+)\s*\)/g
const IssueRE = /(#\d+)/g

export function parseCommits(commits: string): GitCommit[] {
return commits.split('----\n')
.splice(1)
.map((commit) => {
const [firstLine, ..._body] = commit.split('\n')
const [message, shortHash, authorName, authorEmail] = firstLine.split('|')
const body = _body.filter(Boolean).join('\n')

const match = message.match(ConventionalCommitRegex)

const type = match?.groups?.type || ''
const hasBreakingBody = /breaking change:/i.test(body)
const scope = match?.groups?.scope || ''

const isBreaking = Boolean(match?.groups?.breaking || hasBreakingBody)
let description = match?.groups?.description || message

// Extract references from message
const references: Reference[] = []
for (const m of description.matchAll(PullRequestRE)) {
references.push({ type: 'pull-request', value: m[1] })
}
for (const m of description.matchAll(IssueRE)) {
if (!references.some(i => i.value === m[1])) {
references.push({ type: 'issue', value: m[1] })
}
}
references.push({ value: shortHash, type: 'hash' })

// Remove references and normalize
description = description.replace(PullRequestRE, '').trim()

// Find all authors
const authors: GitCommitAuthor[] = [{ name: authorName, email: authorEmail }]
for (const match of body.matchAll(CoAuthoredByRegex)) {
authors.push({
name: (match.groups?.name || '').trim(),
email: (match.groups?.email || '').trim(),
})
}

return {
shortHash,
type,
scope,
message,
description,
body,
authors,
references,
isBreaking,
}
})
}

export async function getRenentCommits() {
const lastTag = await getLastGitTag()
return await getGitDiff(lastTag, 'HEAD').then(parseCommits)
}
Loading