feat(api): changelog version auto create

This commit is contained in:
Lars Hampe 2024-11-01 21:11:50 +01:00
parent 9d1f96a5f5
commit a63f5dea75
6 changed files with 369 additions and 24 deletions

View File

@ -13,6 +13,7 @@
"@hono/sentry": "^1.2.0",
"@hono/zod-openapi": "^0.16.2",
"@scalar/hono-api-reference": "^0.5.149",
"convert-gitmoji": "^0.1.5",
"hono": "^4.6.3",
"semver": "^7.6.3",
"svix": "^1.36.0",

View File

@ -44,42 +44,42 @@ export const createFunc = async ({
userId: string
payload: z.infer<typeof VersionCreateInput>
}) => {
const formattedVersion = semver.coerce(payload.version)
const validVersion = semver.valid(formattedVersion)
const changelogResult = await db.query.changelog.findFirst({
where: and(
eq(changelog.userId, userId),
eq(changelog.id, payload.changelogId),
),
with: {
versions: {
where: and(
eq(changelog_version.changelogId, payload.changelogId),
eq(changelog_version.version, payload.version),
),
},
},
})
if (!changelogResult) {
throw new HTTPException(404, {
message: 'changelog not found',
message: 'Changelog not found',
})
}
if (changelogResult.versions.length) {
throw new HTTPException(409, {
message: 'Version exists already',
})
}
const formattedVersion = semver.coerce(payload.version)
const validVersion = semver.valid(formattedVersion)
if (validVersion === null) {
throw new HTTPException(409, {
message: 'Version is not semver compatible',
})
}
// Check if a version with the same version already exists
const versionResult = await db.query.changelog_version.findFirst({
where: and(
eq(changelog_version.changelogId, payload.changelogId),
eq(changelog_version.version, validVersion),
),
})
if (versionResult) {
throw new HTTPException(409, {
message: 'Version exists already',
})
}
const [versionCreateResult] = await db
.insert(changelog_version)
.values({

View File

@ -0,0 +1,155 @@
import {
changelog,
changelog_commit,
changelog_version,
db,
} from '@boring.tools/database'
import {
VersionCreateAutoInput,
VersionCreateOutput,
} from '@boring.tools/schema'
import { createRoute, type z } from '@hono/zod-openapi'
import { format } from 'date-fns'
import { and, eq, isNull } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import semver from 'semver'
import type { changelogVersionApi } from '.'
import { verifyAuthentication } from '../../utils/authentication'
import { commitsToMarkdown } from '../../utils/git/commitsToMarkdown'
export const route = createRoute({
method: 'post',
path: '/auto',
tags: ['commit'],
request: {
body: {
content: {
'application/json': { schema: VersionCreateAutoInput },
},
},
},
responses: {
201: {
content: {
'application/json': { schema: VersionCreateOutput },
},
description: 'Commits created',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
})
const getNextVersion = ({
version,
isSemver,
}: { version: string; isSemver: boolean }): string => {
if (isSemver) {
if (version === '') {
return '1.0.0'
}
const nextVersion = semver.inc(version, 'patch')
if (!nextVersion) {
throw new Error('Incorrect semver')
}
return nextVersion
}
return format(new Date(), 'dd.MM.yy')
}
export const registerVersionCreateAuto = (api: typeof changelogVersionApi) => {
return api.openapi(route, async (c) => {
const userId = verifyAuthentication(c)
const data: z.infer<typeof VersionCreateAutoInput> = await c.req.json()
const changelogResult = await db.query.changelog.findFirst({
where: and(
eq(changelog.id, data.changelogId),
eq(changelog.userId, userId),
),
with: {
versions: {
columns: {
version: true,
},
orderBy: (_, { asc }) => asc(changelog_version.createdAt),
limit: 1,
},
},
})
if (!changelogResult) {
throw new HTTPException(404, {
message: 'Version could not be created. Changelog not found.',
})
}
if (!changelogResult.isConventional) {
throw new HTTPException(500, {
message: 'Auto generating only works with conventional commits.',
})
}
const commits = await db.query.changelog_commit.findMany({
where: and(
isNull(changelog_commit.versionId),
eq(changelog_commit.changelogId, data.changelogId),
),
})
// @ts-ignore
const markdown = await commitsToMarkdown(commits)
// If no version exists, create the first one
if (changelogResult?.versions.length === 0) {
// If the changelog follows semver starts with version 1.0.0
const inputVersion = changelog.isSemver
? semver.valid(semver.coerce(data.version))
: data.version
const [versionCreateResult] = await db
.insert(changelog_version)
.values({
changelogId: changelogResult.id,
version:
inputVersion ??
getNextVersion({
version: '',
isSemver: changelogResult.isSemver ?? true,
}),
status: 'draft',
markdown,
})
.returning()
await db
.update(changelog_commit)
.set({ versionId: versionCreateResult.id })
.where(isNull(changelog_commit.versionId))
return c.json(versionCreateResult, 201)
}
const [versionCreateResult] = await db
.insert(changelog_version)
.values({
changelogId: changelogResult.id,
version: getNextVersion({
version: data.version ?? changelogResult.versions[0].version,
isSemver: changelogResult.isSemver ?? true,
}),
status: 'draft',
markdown,
})
.returning()
await db
.update(changelog_commit)
.set({ versionId: versionCreateResult.id })
.where(isNull(changelog_commit.versionId))
return c.json(versionCreateResult, 201)
})
}

View File

@ -4,17 +4,20 @@ import { verifyAuthentication } from '../../utils/authentication'
import { type ContextModule, captureSentry } from '../../utils/sentry'
import { byId, byIdFunc } from './byId'
import { create, createFunc } from './create'
import { registerVersionCreateAuto } from './createAuto'
import { remove, removeFunc } from './delete'
import { update, updateFunc } from './update'
const app = new OpenAPIHono<{ Variables: Variables }>()
export const changelogVersionApi = new OpenAPIHono<{ Variables: Variables }>()
const module: ContextModule = {
name: 'changelog',
sub_module: 'version',
}
app.openapi(create, async (c) => {
registerVersionCreateAuto(changelogVersionApi)
changelogVersionApi.openapi(create, async (c) => {
const userId = verifyAuthentication(c)
try {
const payload = await c.req.json()
@ -37,7 +40,7 @@ app.openapi(create, async (c) => {
}
})
app.openapi(byId, async (c) => {
changelogVersionApi.openapi(byId, async (c) => {
const userId = verifyAuthentication(c)
try {
const id = c.req.param('id')
@ -72,7 +75,7 @@ app.openapi(byId, async (c) => {
}
})
app.openapi(update, async (c) => {
changelogVersionApi.openapi(update, async (c) => {
const userId = verifyAuthentication(c)
try {
const id = c.req.param('id')
@ -100,7 +103,7 @@ app.openapi(update, async (c) => {
}
})
app.openapi(remove, async (c) => {
changelogVersionApi.openapi(remove, async (c) => {
const userId = verifyAuthentication(c)
try {
const id = c.req.param('id')
@ -123,4 +126,4 @@ app.openapi(remove, async (c) => {
}
})
export default app
export default changelogVersionApi

View File

@ -0,0 +1,113 @@
import type { CommitOutput } from '@boring.tools/schema'
import type { z } from '@hono/zod-openapi'
import { convert } from 'convert-gitmoji'
import { type GitCommit, parseCommits } from './parseCommit'
export const config = {
types: {
feat: { title: '🚀 Enhancements', semver: 'minor' },
perf: { title: '🔥 Performance', semver: 'patch' },
fix: { title: '🩹 Fixes', semver: 'patch' },
refactor: { title: '💅 Refactors', semver: 'patch' },
docs: { title: '📖 Documentation', semver: 'patch' },
build: { title: '📦 Build', semver: 'patch' },
types: { title: '🌊 Types', semver: 'patch' },
chore: { title: '🏡 Chore' },
examples: { title: '🏀 Examples' },
test: { title: '✅ Tests' },
style: { title: '🎨 Styles' },
ci: { title: '🤖 CI' },
},
}
type Commit = z.infer<typeof CommitOutput>
export const commitsToMarkdown = async (commits: Commit[]) => {
const parsedCommits = await parseCommits(commits)
const typeGroups: Record<string, GitCommit[]> = groupBy(parsedCommits, 'type')
const markdown: string[] = []
const breakingChanges = []
for (const type in config.types) {
const group = typeGroups[type]
if (!group || group.length === 0) {
continue
}
if (type in config.types) {
markdown.push(
'',
`### ${config.types[type as keyof typeof config.types].title}`,
'',
)
for (const commit of group.reverse()) {
const line = formatCommit(commit)
markdown.push(line)
if (commit.isBreaking) {
breakingChanges.push(line)
}
}
}
}
if (breakingChanges.length > 0) {
markdown.push('', '#### ⚠️ Breaking Changes', '', ...breakingChanges)
}
const _authors = new Map<string, { email: Set<string>; github?: string }>()
for (const commit of commits) {
if (!commit.author) {
continue
}
const name = formatName(commit.author.name)
if (!name || name.includes('[bot]')) {
continue
}
if (_authors.has(name)) {
const entry = _authors.get(name)
entry?.email.add(commit.author.email)
} else {
_authors.set(name, { email: new Set([commit.author.email]) })
}
}
const authors = [..._authors.entries()].map((e) => ({ name: e[0], ...e[1] }))
if (authors.length > 0) {
markdown.push(
'',
'### ' + '❤️ Contributors',
'',
...authors.map((i) => {
return `- ${i.name} ${[...i.email]}`
}),
)
}
return convert(markdown.join('\n').trim(), true)
}
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
function groupBy(items: any[], key: string): Record<string, any[]> {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const groups: Record<string, any[]> = {}
for (const item of items) {
groups[item[key]] = groups[item[key]] || []
groups[item[key]].push(item)
}
return groups
}
function formatCommit(commit: GitCommit) {
return `- ${commit.scope ? `**${commit.scope.trim()}:** ` : ''}${
commit.isBreaking ? '⚠️ ' : ''
}${commit.description}`
}
function formatName(name = '') {
return name
.split(' ')
.map((p) => p.trim())
.join(' ')
}

View File

@ -0,0 +1,73 @@
import type { CommitOutput } from '@boring.tools/schema'
import type { z } from '@hono/zod-openapi'
export interface GitCommitAuthor {
name: string
email: string
}
export interface GitCommit {
description: string
type: string
scope: string
authors: GitCommitAuthor[]
isBreaking: boolean
}
type Commit = z.infer<typeof CommitOutput>
export function parseCommits(commits: Commit[]): GitCommit[] {
return commits.map((commit) => parseGitCommit(commit)).filter(Boolean)
}
// 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
const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gim
export function parseGitCommit(commit: Commit): GitCommit | null {
const match = commit.subject.match(ConventionalCommitRegex)
if (!match) {
return {
...commit,
authors: [],
description: '',
type: 'none',
scope: 'none',
isBreaking: false,
}
}
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
const type = match.groups?.['type'] ?? ''
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
const scope = match.groups?.['scope'] ?? ''
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
const isBreaking = Boolean(match.groups?.['breaking'])
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
const description = match.groups?.['description'] ?? ''
// Find all authors
const authors: GitCommitAuthor[] = [commit.author]
if (commit?.body) {
for (const match of commit.body.matchAll(CoAuthoredByRegex)) {
authors.push({
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
name: (match.groups?.['name'] ?? '').trim(),
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
email: (match.groups?.['email'] ?? '').trim(),
})
}
}
return {
authors,
description,
type,
scope,
isBreaking,
}
}