feat(api): changelog version auto create
This commit is contained in:
parent
9d1f96a5f5
commit
a63f5dea75
@ -13,6 +13,7 @@
|
|||||||
"@hono/sentry": "^1.2.0",
|
"@hono/sentry": "^1.2.0",
|
||||||
"@hono/zod-openapi": "^0.16.2",
|
"@hono/zod-openapi": "^0.16.2",
|
||||||
"@scalar/hono-api-reference": "^0.5.149",
|
"@scalar/hono-api-reference": "^0.5.149",
|
||||||
|
"convert-gitmoji": "^0.1.5",
|
||||||
"hono": "^4.6.3",
|
"hono": "^4.6.3",
|
||||||
"semver": "^7.6.3",
|
"semver": "^7.6.3",
|
||||||
"svix": "^1.36.0",
|
"svix": "^1.36.0",
|
||||||
|
@ -44,42 +44,42 @@ export const createFunc = async ({
|
|||||||
userId: string
|
userId: string
|
||||||
payload: z.infer<typeof VersionCreateInput>
|
payload: z.infer<typeof VersionCreateInput>
|
||||||
}) => {
|
}) => {
|
||||||
const formattedVersion = semver.coerce(payload.version)
|
|
||||||
const validVersion = semver.valid(formattedVersion)
|
|
||||||
|
|
||||||
const changelogResult = await db.query.changelog.findFirst({
|
const changelogResult = await db.query.changelog.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(changelog.userId, userId),
|
eq(changelog.userId, userId),
|
||||||
eq(changelog.id, payload.changelogId),
|
eq(changelog.id, payload.changelogId),
|
||||||
),
|
),
|
||||||
|
with: {
|
||||||
|
versions: {
|
||||||
|
where: and(
|
||||||
|
eq(changelog_version.changelogId, payload.changelogId),
|
||||||
|
eq(changelog_version.version, payload.version),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!changelogResult) {
|
if (!changelogResult) {
|
||||||
throw new HTTPException(404, {
|
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) {
|
if (validVersion === null) {
|
||||||
throw new HTTPException(409, {
|
throw new HTTPException(409, {
|
||||||
message: 'Version is not semver compatible',
|
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
|
const [versionCreateResult] = await db
|
||||||
.insert(changelog_version)
|
.insert(changelog_version)
|
||||||
.values({
|
.values({
|
||||||
|
155
apps/api/src/changelog/version/createAuto.ts
Normal file
155
apps/api/src/changelog/version/createAuto.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
@ -4,17 +4,20 @@ import { verifyAuthentication } from '../../utils/authentication'
|
|||||||
import { type ContextModule, captureSentry } from '../../utils/sentry'
|
import { type ContextModule, captureSentry } from '../../utils/sentry'
|
||||||
import { byId, byIdFunc } from './byId'
|
import { byId, byIdFunc } from './byId'
|
||||||
import { create, createFunc } from './create'
|
import { create, createFunc } from './create'
|
||||||
|
import { registerVersionCreateAuto } from './createAuto'
|
||||||
import { remove, removeFunc } from './delete'
|
import { remove, removeFunc } from './delete'
|
||||||
import { update, updateFunc } from './update'
|
import { update, updateFunc } from './update'
|
||||||
|
|
||||||
const app = new OpenAPIHono<{ Variables: Variables }>()
|
export const changelogVersionApi = new OpenAPIHono<{ Variables: Variables }>()
|
||||||
|
|
||||||
const module: ContextModule = {
|
const module: ContextModule = {
|
||||||
name: 'changelog',
|
name: 'changelog',
|
||||||
sub_module: 'version',
|
sub_module: 'version',
|
||||||
}
|
}
|
||||||
|
|
||||||
app.openapi(create, async (c) => {
|
registerVersionCreateAuto(changelogVersionApi)
|
||||||
|
|
||||||
|
changelogVersionApi.openapi(create, async (c) => {
|
||||||
const userId = verifyAuthentication(c)
|
const userId = verifyAuthentication(c)
|
||||||
try {
|
try {
|
||||||
const payload = await c.req.json()
|
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)
|
const userId = verifyAuthentication(c)
|
||||||
try {
|
try {
|
||||||
const id = c.req.param('id')
|
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)
|
const userId = verifyAuthentication(c)
|
||||||
try {
|
try {
|
||||||
const id = c.req.param('id')
|
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)
|
const userId = verifyAuthentication(c)
|
||||||
try {
|
try {
|
||||||
const id = c.req.param('id')
|
const id = c.req.param('id')
|
||||||
@ -123,4 +126,4 @@ app.openapi(remove, async (c) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default app
|
export default changelogVersionApi
|
||||||
|
113
apps/api/src/utils/git/commitsToMarkdown.ts
Normal file
113
apps/api/src/utils/git/commitsToMarkdown.ts
Normal 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(' ')
|
||||||
|
}
|
73
apps/api/src/utils/git/parseCommit.ts
Normal file
73
apps/api/src/utils/git/parseCommit.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user