diff --git a/.changeset/breezy-candies-hear.md b/.changeset/breezy-candies-hear.md new file mode 100644 index 00000000..1eed7b76 --- /dev/null +++ b/.changeset/breezy-candies-hear.md @@ -0,0 +1,5 @@ +--- +'@smooai/utils': patch +--- + +Fix hono logger to have more context. diff --git a/.changeset/plenty-donkeys-visit.md b/.changeset/plenty-donkeys-visit.md new file mode 100644 index 00000000..5770b2dd --- /dev/null +++ b/.changeset/plenty-donkeys-visit.md @@ -0,0 +1,5 @@ +--- +'@smooai/utils': patch +--- + +Add utility for using AI to generate branch name, and improve hono logging. diff --git a/.gitignore b/.gitignore index 61bd6652..0deed53a 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,6 @@ yarn.lock # ignore package-lock.json package-lock.json -.vscode \ No newline at end of file +.vscode + +.envrc \ No newline at end of file diff --git a/package.json b/package.json index 9e30912a..fe61e95d 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ } }, "bin": { - "create-entry-points": "./dist/scripts/createEntryPoints.js" + "create-entry-points": "./dist/scripts/createEntryPoints.js", + "generate-git-branch": "./dist/scripts/generateGitBranch.js" }, "files": [ "dist/**" @@ -58,6 +59,7 @@ "ci:publish": "pnpm build && pnpm changeset publish", "createEntryPoints": "pnpm vite-node ./src/scripts/createEntryPoints.ts -i \"src/**/*.ts\"", "format": "prettier --write \"**/*.{ts,tsx,md,json,js,cjs,mjs}\"", + "generateGitBranch": "pnpm vite-node ./src/utils/generate-git-branch.ts", "preinstall": "npx only-allow pnpm", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", @@ -77,8 +79,11 @@ "hono": "^4.7.2", "http-status-codes": "^2.3.0", "libphonenumber-js": "^1.12.4", + "openai": "^5.15.0", + "picocolors": "^1.1.1", "zod": "^3.24.2", - "zod-validation-error": "^3.4.0" + "zod-validation-error": "^3.4.0", + "zx": "^8.8.1" }, "devDependencies": { "@changesets/cli": "^2.28.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 682b916c..ce70ce30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,12 +39,21 @@ importers: libphonenumber-js: specifier: ^1.12.4 version: 1.12.5 + openai: + specifier: ^5.15.0 + version: 5.15.0(zod@3.24.2) + picocolors: + specifier: ^1.1.1 + version: 1.1.1 zod: specifier: ^3.24.2 version: 3.24.2 zod-validation-error: specifier: ^3.4.0 version: 3.4.0(zod@3.24.2) + zx: + specifier: ^8.8.1 + version: 8.8.1 devDependencies: '@changesets/cli': specifier: ^2.28.1 @@ -2982,6 +2991,18 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + openai@5.15.0: + resolution: {integrity: sha512-kcUdws8K/A8m02I+IqFBwO51gS+87GP89yWEufGbzEi8anBz4FB/bti2QxaJdGwwY4mwJGzx85XO7TuL/Tpu1w==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -4008,6 +4029,11 @@ packages: zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zx@8.8.1: + resolution: {integrity: sha512-qvsKBnvWHstHKCluKPlEgI/D3+mdiQyMoSSeFR8IX/aXzWIas5A297KxKgPJhuPXdrR6ma0Jzx43+GQ/8sqbrw==} + engines: {node: '>= 12.17.0'} + hasBin: true + snapshots: '@ampproject/remapping@2.3.0': @@ -7372,6 +7398,10 @@ snapshots: dependencies: mimic-function: 5.0.1 + openai@5.15.0(zod@3.24.2): + optionalDependencies: + zod: 3.24.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8400,3 +8430,5 @@ snapshots: zod: 3.24.2 zod@3.24.2: {} + + zx@8.8.1: {} diff --git a/src/api/hono.ts b/src/api/hono.ts index 3a62e6b4..b4140ce4 100644 --- a/src/api/hono.ts +++ b/src/api/hono.ts @@ -1,6 +1,6 @@ import { isRunningLocally } from '@/env'; import { HumanReadableSchemaError } from '@/validation/standardSchema'; -import AwsServerLogger from '@smooai/logger/AwsServerLogger'; +import AwsServerLogger, { HttpResponse } from '@smooai/logger/AwsServerLogger'; import { APIGatewayProxyEventV2, Context } from 'aws-lambda'; import { Hono } from 'hono'; import { handle, LambdaContext, LambdaEvent } from 'hono/aws-lambda'; @@ -18,14 +18,27 @@ export function addHonoMiddleware(_app: Hono): Hono { const app = _app ?? new Hono(); app.use(requestId()); + app.use(async (c, next) => { + const namespace = `[${c.req.method}] ${c.req.path}`; + logger.addRequestContext(c.req); + logger.addContext({ + namespace, + honoRequestId: c.get('requestId'), + }); + logger.info(`Request started`); + await next(); + }); + + app.use(async (c, next) => { + const start = Date.now(); + await next(); + const duration = Date.now() - start; + logger.addResponseContext(c.res as unknown as HttpResponse); + logger.info(`Request completed in ${duration}ms`); + }); + app.use(async (c, next) => { honoLogger((str, ...rest) => { - const namespace = `[${c.req.method}] ${c.req.path}`; - logger.addRequestContext(c.req); - logger.addContext({ - namespace, - honoRequestId: c.get('requestId'), - }); logger.info(str, ...rest); }); await next(); diff --git a/src/utils/generate-git-branch.ts b/src/utils/generate-git-branch.ts new file mode 100644 index 00000000..b767519d --- /dev/null +++ b/src/utils/generate-git-branch.ts @@ -0,0 +1,165 @@ +#!/usr/bin/env tsx +import { createInterface } from 'node:readline'; +import OpenAI from 'openai'; +import pc from 'picocolors'; +import { $$ } from './zx-factory'; + +interface BranchGenerationOptions { + pullFromMain?: boolean; +} + +async function requestUserInput(prompt: string): Promise { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(pc.cyan(prompt), (answer: string) => { + rl.close(); + const trimmedAnswer = answer.trim(); + if (trimmedAnswer) { + console.log(pc.green(`→ ${trimmedAnswer}`)); + } + resolve(trimmedAnswer); + }); + }); +} + +async function generateBranchName(workDescription: string): Promise { + const openaiApiKey = process.env.OPENAI_API_KEY; + + if (!openaiApiKey || typeof openaiApiKey !== 'string') { + throw new Error('OPENAI_API_KEY is not configured'); + } + + const openai = new OpenAI({ + apiKey: openaiApiKey, + }); + + const prompt = `Generate a git branch name for the following work description. +The branch name should be: +- Descriptive but concise (max 40 characters to leave room for date) +- Use kebab-case (lowercase with hyphens) +- Avoid special characters except hyphens +- Be meaningful and related to the work +- DO NOT include any dates, timestamps, or time-related information +- Focus on the feature/change being implemented + +Work description: ${workDescription} + +Return only the branch name, nothing else.`; + + try { + const completion = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { + role: 'user', + content: prompt, + }, + ], + max_tokens: 50, + temperature: 0.3, + }); + + const branchName = completion.choices[0]?.message?.content?.trim(); + + if (!branchName) { + throw new Error('Failed to generate branch name from OpenAI'); + } + + // Clean up the branch name to ensure it's valid + const cleanedBranchName = branchName + .replace(/[^a-zA-Z0-9\-_/]/g, '-') // Replace invalid characters with hyphens + .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen + .replace(/^-|-$/g, '') // Remove leading/trailing hyphens + .replace(/\d{4}-\d{2}-\d{2}/g, '') // Remove date patterns (YYYY-MM-DD) + .replace(/\d{2}-\d{2}-\d{4}/g, '') // Remove date patterns (MM-DD-YYYY) + .replace(/-+/g, '-') // Replace multiple hyphens again after date removal + .replace(/^-|-$/g, '') // Remove leading/trailing hyphens again + .toLowerCase(); + + // Add today's date in YYYY-MM-DD format + const today = new Date(); + const dateString = today.toISOString().split('T')[0]; // YYYY-MM-DD format + + return `${cleanedBranchName}-${dateString}`; + } catch (_error) { + console.error('Error generating branch name:', _error); + throw _error; + } +} + +async function createAndSwitchToBranch(branchName: string, options: BranchGenerationOptions = {}): Promise { + const { pullFromMain = true } = options; + + console.log(`\n${pc.blue('🔄')} Creating branch: ${pc.bold(branchName)}`); + + try { + // Create and switch to the new branch using proper zx syntax + await $$`git checkout -b ${branchName}`; + + if (pullFromMain) { + console.log(pc.yellow('📥 Pulling latest changes from main...')); + try { + // First, fetch the latest changes + await $$`git fetch origin`; + + // Then merge the latest main into the new branch + await $$`git merge origin/main`; + console.log(pc.green('✅ Successfully pulled and merged latest changes from main')); + } catch (_error) { + console.warn(pc.yellow('⚠️ Warning: Could not pull from main. You may need to resolve conflicts manually.')); + } + } + + console.log(`\n${pc.green('🎉')} Successfully created and switched to branch: ${pc.bold(pc.green(branchName))}`); + console.log(`${pc.cyan('📝')} Ready to start working on: ${pc.bold(branchName)}`); + } catch (_error) { + console.error(pc.red(`Git command failed`)); + process.exit(1); + } +} + +async function main(): Promise { + try { + console.log(pc.bold(pc.blue('🚀 Git Branch Generator'))); + console.log(pc.blue('======================\n')); + + // Request work description from user + const workDescription = await requestUserInput('Please describe the work you want to do: '); + + if (!workDescription) { + console.error(pc.red('❌ Work description cannot be empty')); + process.exit(1); + } + + // Ask about pulling from main + const pullFromMainInput = await requestUserInput('\nPull latest changes from main after creating branch? (y/n, default: y): '); + const pullFromMain = pullFromMainInput.toLowerCase() !== 'n' && pullFromMainInput.toLowerCase() !== 'no'; + + console.log(`\n${pc.magenta('🤖')} Generating branch name with AI...`); + + // Generate branch name using OpenAI + const branchName = await generateBranchName(workDescription); + + console.log(`${pc.green('✨')} Generated branch name: ${pc.bold(pc.green(branchName))}`); + + // Confirm with user + const confirm = await requestUserInput(`\nCreate branch "${pc.bold(branchName)}"? (y/n, default: y): `); + + if (confirm.toLowerCase() === 'n' || confirm.toLowerCase() === 'no') { + console.log(pc.red('❌ Branch creation cancelled')); + process.exit(0); + } + + // Create and switch to the branch + await createAndSwitchToBranch(branchName, { pullFromMain }); + } catch (error) { + console.error(pc.red('❌ Error:'), error); + process.exit(1); + } +} + +main(); diff --git a/src/utils/zx-factory.ts b/src/utils/zx-factory.ts new file mode 100644 index 00000000..81927102 --- /dev/null +++ b/src/utils/zx-factory.ts @@ -0,0 +1,22 @@ +import { $ } from 'zx'; + +// Base zx instance with configuration +export const $$ = $({ + verbose: false, +}); + +// Quiet zx instance for silent operations +export const $$quiet = $({ quiet: true }); + +// Factory function to create zx instances with custom options +export function createZxInstance(options: { cwd?: string; quiet?: boolean; verbose?: boolean } = {}) { + return $({ + verbose: options.verbose ?? false, + quiet: options.quiet ?? false, + ...options, + }); +} + +// Common patterns +export const $$cwd = (cwd: string) => createZxInstance({ cwd }); +export const $$quietCwd = (cwd: string) => createZxInstance({ cwd, quiet: true }); diff --git a/tsup.config.ts b/tsup.config.ts index f7ea91af..7ede8a9d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -5,7 +5,9 @@ export default defineConfig((options: Options) => ({ 'src/index.ts', 'src/validation/standardSchema.ts', 'src/validation/phoneNumber.ts', + 'src/utils/zx-factory.ts', 'src/utils/sleep.ts', + 'src/utils/generate-git-branch.ts', 'src/scripts/createEntryPoints.ts', 'src/file/findFile.ts', 'src/error/errorHandler.ts',