From a63f5dea75fca964b30b9feaf952fe3280576eb2 Mon Sep 17 00:00:00 2001 From: Lars Hampe Date: Fri, 1 Nov 2024 21:11:50 +0100 Subject: [PATCH] feat(api): changelog version auto create --- apps/api/package.json | 1 + apps/api/src/changelog/version/create.ts | 36 ++--- apps/api/src/changelog/version/createAuto.ts | 155 +++++++++++++++++++ apps/api/src/changelog/version/index.ts | 15 +- apps/api/src/utils/git/commitsToMarkdown.ts | 113 ++++++++++++++ apps/api/src/utils/git/parseCommit.ts | 73 +++++++++ 6 files changed, 369 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/changelog/version/createAuto.ts create mode 100644 apps/api/src/utils/git/commitsToMarkdown.ts create mode 100644 apps/api/src/utils/git/parseCommit.ts diff --git a/apps/api/package.json b/apps/api/package.json index c1a5f81..2c71e46 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/changelog/version/create.ts b/apps/api/src/changelog/version/create.ts index 5f1a2bc..ef96cde 100644 --- a/apps/api/src/changelog/version/create.ts +++ b/apps/api/src/changelog/version/create.ts @@ -44,42 +44,42 @@ export const createFunc = async ({ userId: string payload: z.infer }) => { - 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({ diff --git a/apps/api/src/changelog/version/createAuto.ts b/apps/api/src/changelog/version/createAuto.ts new file mode 100644 index 0000000..b4bb119 --- /dev/null +++ b/apps/api/src/changelog/version/createAuto.ts @@ -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 = 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) + }) +} diff --git a/apps/api/src/changelog/version/index.ts b/apps/api/src/changelog/version/index.ts index b73587d..7a1f8a5 100644 --- a/apps/api/src/changelog/version/index.ts +++ b/apps/api/src/changelog/version/index.ts @@ -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 diff --git a/apps/api/src/utils/git/commitsToMarkdown.ts b/apps/api/src/utils/git/commitsToMarkdown.ts new file mode 100644 index 0000000..b5a138d --- /dev/null +++ b/apps/api/src/utils/git/commitsToMarkdown.ts @@ -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 +export const commitsToMarkdown = async (commits: Commit[]) => { + const parsedCommits = await parseCommits(commits) + + const typeGroups: Record = 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; 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: +function groupBy(items: any[], key: string): Record { + // biome-ignore lint/suspicious/noExplicitAny: + const groups: Record = {} + 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(' ') +} diff --git a/apps/api/src/utils/git/parseCommit.ts b/apps/api/src/utils/git/parseCommit.ts new file mode 100644 index 0000000..909187d --- /dev/null +++ b/apps/api/src/utils/git/parseCommit.ts @@ -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 + +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 = + /(?:.+:|(\uD83C[\uDF00-\uDFFF])|(\uD83D[\uDC00-\uDE4F\uDE80-\uDEFF])|[\u2600-\u2B55])?( *)?(?[a-z]+)(\((?.+)\))?(?!)?: (?.+)/i +const CoAuthoredByRegex = /co-authored-by:\s*(?.+)(<(?.+)>)/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: + const type = match.groups?.['type'] ?? '' + + // biome-ignore lint/complexity/useLiteralKeys: + const scope = match.groups?.['scope'] ?? '' + + // biome-ignore lint/complexity/useLiteralKeys: + const isBreaking = Boolean(match.groups?.['breaking']) + // biome-ignore lint/complexity/useLiteralKeys: + 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: + name: (match.groups?.['name'] ?? '').trim(), + // biome-ignore lint/complexity/useLiteralKeys: + email: (match.groups?.['email'] ?? '').trim(), + }) + } + } + + return { + authors, + description, + type, + scope, + isBreaking, + } +}