UNPKG

gitvan

Version:

Autonomic Git-native development automation platform with AI-powered workflows

656 lines (591 loc) 20.1 kB
/** * GitVan v2 AI Provider - Configurable AI Integration * Uses provider factory for configurable AI backends */ import { generateText as aiGenerateText, streamText as aiStreamText } from 'ai'; import { z } from 'zod'; import { createLogger } from '../utils/logger.mjs'; import { createAIProvider, checkAIProviderAvailability } from './provider-factory.mjs'; import { GITVAN_COMPLETE_CONTEXT } from './prompts/gitvan-complete-context.mjs'; import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; const logger = createLogger('ai-provider'); /** * Validate GitVan job AST and extract metadata * @param {string} code - JavaScript code to validate * @returns {object} Validation result with metadata */ function validateJobAST(code) { try { // Parse the JavaScript code const ast = parse(code, { sourceType: 'module', allowImportExportEverywhere: true, allowReturnOutsideFunction: true, plugins: ['jsx', 'typescript', 'decorators-legacy'] }); const errors = []; const metadata = {}; // Traverse the AST to extract metadata and validate structure traverse(ast, { // Check for defineJob call CallExpression(path) { if (path.node.callee.name === 'defineJob') { const args = path.node.arguments; if (args.length > 0 && args[0].type === 'ObjectExpression') { const jobConfig = args[0]; // Extract meta information const metaProp = jobConfig.properties.find(prop => prop.key.name === 'meta' && prop.value.type === 'ObjectExpression' ); if (metaProp) { metaProp.value.properties.forEach(prop => { if (prop.key.name === 'name' && prop.value.type === 'StringLiteral') { metadata.name = prop.value.value; } else if (prop.key.name === 'desc' && prop.value.type === 'StringLiteral') { metadata.desc = prop.value.value; } else if (prop.key.name === 'tags' && prop.value.type === 'ArrayExpression') { metadata.tags = prop.value.elements .filter(el => el.type === 'StringLiteral') .map(el => el.value); } else if (prop.key.name === 'author' && prop.value.type === 'StringLiteral') { metadata.author = prop.value.value; } else if (prop.key.name === 'version' && prop.value.type === 'StringLiteral') { metadata.version = prop.value.value; } }); } // Extract on configuration const onProp = jobConfig.properties.find(prop => prop.key.name === 'on' && prop.value.type === 'ObjectExpression' ); if (onProp) { metadata.on = {}; onProp.value.properties.forEach(prop => { if (prop.value.type === 'StringLiteral') { metadata.on[prop.key.name] = prop.value.value; } }); } } } }, // Check for proper imports ImportDeclaration(path) { const source = path.node.source.value; if (source.includes('gitvan') && !source.includes('defineJob')) { errors.push(`Missing defineJob import from ${source}`); } }, // Check for async run function FunctionExpression(path) { if (path.node.async && path.node.params.length >= 1) { const firstParam = path.node.params[0]; if (firstParam.type === 'ObjectPattern') { const hasCtx = firstParam.properties.some(prop => prop.key.name === 'ctx' || prop.key.name === 'payload' || prop.key.name === 'meta' ); if (!hasCtx) { errors.push('Run function should destructure { ctx, payload, meta }'); } } } } }); // Check for required structure let hasDefineJob = false; let hasDefaultExport = false; traverse(ast, { CallExpression(path) { if (path.node.callee.name === 'defineJob') { hasDefineJob = true; } }, ExportDefaultDeclaration(path) { hasDefaultExport = true; } }); if (!hasDefineJob) { errors.push('Missing defineJob() call'); } if (!hasDefaultExport) { errors.push('Missing default export'); } return { valid: errors.length === 0, errors, metadata, ast }; } catch (error) { return { valid: false, errors: [`AST parsing failed: ${error.message}`], metadata: {}, ast: null }; } } // Job specification schema for structured generation const JobSpecSchema = z.union([ // AI-generated complete job file (preferred) z.object({ name: z.string().optional().describe('Job name'), desc: z.string().describe('Clear description of what the job does'), tags: z.array(z.string()).describe('Tags for categorization'), author: z.string().default('GitVan AI'), version: z.string().default('1.0.0'), on: z.union([ z.string(), z.array(z.string()), z.object({ tagCreate: z.string().optional(), push: z.string().optional(), pathChanged: z.array(z.string()).optional(), cron: z.string().optional() }) ]).optional().describe('Event triggers'), run: z.string().describe('Complete GitVan job file content') }), // AI-generated implementation-focused structure z.object({ name: z.string().optional().describe('Job name'), description: z.string().describe('Clear description of what the job does'), version: z.string().default('1.0.0'), meta: z.object({ author: z.string().default('GitVan AI'), license: z.string().optional() }).optional(), on: z.union([ z.string(), z.array(z.string()), z.object({ tagCreate: z.string().optional(), push: z.string().optional(), pathChanged: z.array(z.string()).optional(), cron: z.string().optional() }) ]).optional().describe('Event triggers'), implementation: z.string().describe('JavaScript implementation code') }), // Nested structure (legacy) z.object({ meta: z.object({ name: z.string().optional().describe('Job name'), desc: z.string().describe('Clear description of what the job does'), tags: z.array(z.string()).describe('Tags for categorization'), author: z.string().default('GitVan AI'), version: z.string().default('1.0.0') }), on: z.union([ z.string(), z.array(z.string()), z.object({ tagCreate: z.string().optional(), push: z.string().optional(), pathChanged: z.array(z.string()).optional() }) ]).optional().describe('Event triggers'), run: z.object({ implementation: z.string().describe('JavaScript implementation code').optional(), function: z.string().describe('JavaScript function code').optional() }).optional(), // Legacy schema support config: z.object({ cron: z.string().optional().describe('Cron expression for scheduling'), on: z.union([ z.string(), z.array(z.string()), z.object({ tagCreate: z.string().optional(), push: z.string().optional(), pathChanged: z.array(z.string()).optional() }) ]).optional().describe('Event triggers'), schedule: z.string().optional().describe('Schedule expression') }).optional(), implementation: z.object({ operations: z.array(z.object({ type: z.enum(['log', 'file-read', 'file-write', 'file-copy', 'file-move', 'git-commit', 'git-note', 'template-render', 'pack-apply']), description: z.string(), parameters: z.record(z.any()).optional() })), returnValue: z.object({ success: z.string(), artifacts: z.array(z.string()) }) }).optional() }) ]); /** * Generate text using AI provider */ export async function generateText({ prompt, model = 'qwen3-coder:30b', options = {}, config = {} }) { const startTime = Date.now(); try { // Use configurable provider factory const provider = await createAIProvider(config); // Check if this is a mock provider if (provider.provider === 'mock') { const result = await provider.doGenerate({ prompt }); const duration = Date.now() - startTime; return { output: result.text, model: provider.model || model, provider: provider.provider || 'mock', options, duration, success: true }; } const result = await aiGenerateText({ model: provider, prompt, ...options }); const duration = Date.now() - startTime; return { output: result.text, model: provider.model || model, provider: provider.provider || 'unknown', options, duration, success: true }; } catch (error) { logger.error('AI generation failed:', error.message); throw error; } } /** * Generate structured job specification */ export async function generateJobSpec({ prompt, model = 'qwen3-coder:30b', options = {}, config = {} }) { try { // Use the AI provider to generate complete GitVan job file const fullPrompt = `${GITVAN_COMPLETE_CONTEXT}\n\nGenerate a complete GitVan job file for: ${prompt}. Return a complete, working GitVan job file that can be executed immediately.`; const result = await generateText({ prompt: fullPrompt, model, options, config }); // Extract the job code from the response let jobCode = result.output.trim(); // Remove markdown code blocks if present if (jobCode.startsWith('```javascript')) { jobCode = jobCode.replace(/^```javascript\s*/, '').replace(/\s*```$/, ''); } else if (jobCode.startsWith('```js')) { jobCode = jobCode.replace(/^```js\s*/, '').replace(/\s*```$/, ''); } else if (jobCode.startsWith('```')) { jobCode = jobCode.replace(/^```\s*/, '').replace(/\s*```$/, ''); } // Validate the generated code using AST parsing const validationResult = validateJobAST(jobCode); if (!validationResult.valid) { logger.warn('AST validation failed:', validationResult.errors); // Still use the code, but log the issues } // Extract metadata from the AST for the spec const spec = { name: validationResult.metadata?.name || 'ai-generated-job', desc: validationResult.metadata?.desc || `Generated job for: ${prompt.substring(0, 50)}...`, tags: validationResult.metadata?.tags || ["ai-generated", "automation"], author: validationResult.metadata?.author || "GitVan AI", version: validationResult.metadata?.version || "1.0.0", on: validationResult.metadata?.on || {}, code: jobCode }; return { spec, model: result.model, provider: result.provider, duration: result.duration, success: true }; } catch (error) { logger.error('Job generation failed:', error.message); // Fallback to a simple working job const fallbackCode = `import { defineJob, useGit, useTemplate, useNotes } from 'file:///Users/sac/gitvan/src/index.mjs' import { readFile, writeFile } from 'node:fs/promises' export default defineJob({ meta: { name: "fallback-job", desc: "Fallback job due to generation error", tags: ["fallback", "error"], author: "GitVan AI", version: "1.0.0" }, async run({ ctx, payload, meta }) { try { const git = useGit(); const template = useTemplate(); const notes = useNotes(); console.log("Executing fallback job"); await writeFile('fallback-output.txt', \`Fallback job executed at \${new Date().toISOString()}\`); await notes.write(\`Fallback job completed: \${meta.desc}\`); return { ok: true, artifacts: ['fallback-output.txt'], summary: "Fallback job completed successfully" }; } catch (error) { console.error('Fallback job failed:', error.message); return { ok: false, error: error.message, artifacts: [] }; } } })`; return { spec: { name: 'fallback-job', desc: 'Fallback job due to generation error', tags: ['fallback', 'error'], author: 'GitVan AI', version: '1.0.0', code: fallbackCode }, model: 'fallback', provider: 'fallback', duration: 0, success: false, error: error.message }; } } /** * Generate working job code */ export async function generateWorkingJob({ prompt, model = 'qwen3-coder:30b', options = {}, config = {} }) { try { // First generate the specification const { spec } = await generateJobSpec({ prompt, model, options, config }); // Then generate working code from the spec const jobCode = generateWorkingJobCode(spec); // Get provider info const provider = await createAIProvider(config); return { spec, code: jobCode, model: provider.model || model, provider: provider.provider || 'unknown', success: true, working: true // This is actually working code, not a skeleton! }; } catch (error) { logger.error('Working job generation failed:', error.message); throw error; } } /** * Stream text generation */ export async function streamText({ prompt, model = 'qwen3-coder:30b', options = {}, config = {} }) { try { const provider = await createAIProvider(config); const result = aiStreamText({ model: provider, prompt, ...options }); return result; } catch (error) { logger.error('Stream generation failed:', error.message); throw error; } } /** * Check if AI provider is available */ export async function checkAIAvailability(config = {}) { try { // Use the provider factory to check availability return await checkAIProviderAvailability(config); } catch (error) { return { available: false, provider: 'unknown', model: 'unknown', message: `AI provider error: ${error.message}`, error: error.message }; } } /** * Generate working job code from specification */ function generateWorkingJobCode(spec) { // If the AI generated complete job code, use it directly if (spec.code && typeof spec.code === 'string') { return spec.code; } // If the AI generated a complete job file, use it directly if (spec.run && typeof spec.run === 'string') { return spec.run; } // If the AI generated implementation code directly, wrap it in defineJob if (spec.implementation && typeof spec.implementation === 'string') { const name = spec.name || 'ai-generated-job'; const desc = spec.description || spec.desc || 'AI-generated job'; const author = spec.meta?.author || spec.author || 'GitVan AI'; const version = spec.version || '1.0.0'; const on = spec.on || {}; return `import { defineJob, useGit, useTemplate, useNotes, useWorktree, usePack, useSchedule, useReceipt, useLock } from 'file:///Users/sac/gitvan/src/index.mjs' export default defineJob({ meta: { name: "${name}", desc: "${desc}", author: "${author}", version: "${version}" }, ${Object.keys(on).length > 0 ? `on: ${JSON.stringify(on)},` : ''} async run({ ctx, payload, meta }) { ${spec.implementation} } })`; } const { meta, on, run, config, implementation } = spec; // If the AI generated actual JavaScript code in run.implementation or run.function, use it directly if (run && (run.implementation || run.function)) { const code = run.implementation || run.function; return `import { defineJob, useGit, useTemplate, useNotes } from 'file:///Users/sac/gitvan/src/index.mjs' import { readFile, writeFile } from 'node:fs/promises' export default defineJob({ meta: { ${meta.name ? `name: "${meta.name}",` : ''} desc: "${meta.desc}", tags: ${JSON.stringify(meta.tags)}, author: "${meta.author}", version: "${meta.version}" }, ${on ? `on: ${JSON.stringify(on)},` : ''} ${config ? `config: ${JSON.stringify(config, null, 2)},` : ''} async run({ ctx, payload, meta }) { ${code} } })`; } // Fallback to legacy operations-based generation if (implementation && implementation.operations) { const operationsCode = implementation.operations.map(op => { switch (op.type) { case 'file-copy': return `// ${op.description}\n const sourceContent = await readFile('${op.parameters?.source || 'source.txt'}')\n await writeFile('${op.parameters?.target || 'backup.txt'}', sourceContent)`; case 'file-write': return `// ${op.description}\n await writeFile('${op.parameters?.path || 'output.txt'}', '${op.parameters?.content || 'Generated content'}')`; case 'file-read': return `// ${op.description}\n const content = await readFile('${op.parameters?.path || 'input.txt'}')`; case 'git-commit': return `// ${op.description}\n await git.commit('${op.parameters?.message || 'Automated commit'}')`; case 'git-note': return `// ${op.description}\n await notes.write('${op.parameters?.content || 'Job executed'}')`; case 'template-render': return `// ${op.description}\n const rendered = await template.render('${op.parameters?.template || 'template.njk'}', data)`; case 'log': return `// ${op.description}\n console.log('${op.description}')`; default: return `// ${op.description}`; } }).join('\n '); return `import { defineJob, useGit, useTemplate, useNotes } from 'file:///Users/sac/gitvan/src/index.mjs' import { readFile, writeFile } from 'node:fs/promises' export default defineJob({ meta: { desc: "${meta.desc}", tags: ${JSON.stringify(meta.tags)}, author: "${meta.author}", version: "${meta.version}" }, ${config ? `config: ${JSON.stringify(config, null, 2)},` : ''} async run({ ctx, payload, meta }) { try { const git = useGit(); const template = useTemplate(); const notes = useNotes(); console.log("Executing job: ${meta.desc}"); // Execute operations ${operationsCode} return { ok: true, artifacts: ${JSON.stringify(implementation?.returnValue?.artifacts || ['output.txt'])}, summary: "${implementation?.returnValue?.success || 'Task completed successfully'}" }; } catch (error) { console.error('Job failed:', error.message); return { ok: false, error: error.message, artifacts: [] }; } } })`; } // Ultimate fallback - generate a simple working job return `import { defineJob, useGit, useTemplate, useNotes } from 'file:///Users/sac/gitvan/src/index.mjs' import { readFile, writeFile } from 'node:fs/promises' export default defineJob({ meta: { desc: "${meta.desc}", tags: ${JSON.stringify(meta.tags)}, author: "${meta.author}", version: "${meta.version}" }, async run({ ctx, payload, meta }) { try { const git = useGit(); const template = useTemplate(); const notes = useNotes(); console.log("Executing job: ${meta.desc}"); // Simple working implementation await writeFile('job-output.txt', \`Job executed at \${new Date().toISOString()}\`); await notes.write(\`Job completed: \${meta.desc}\`); return { ok: true, artifacts: ['job-output.txt'], summary: "Job completed successfully" }; } catch (error) { console.error('Job failed:', error.message); return { ok: false, error: error.message, artifacts: [] }; } } })`; }