From 01bb760c9a478515e495fb132d446bf9e409a881 Mon Sep 17 00:00:00 2001 From: cghislai Date: Sun, 8 Jun 2025 05:25:26 +0200 Subject: [PATCH] WIP --- .../prompts-to-test-spec/src/index.ts | 57 +- .../services/__tests__/gemini-service.test.ts | 435 --------- .../__tests__/model-stream-service.test.ts | 517 ++++++++++ .../src/services/gemini-project-processor.ts | 891 ++++++++++-------- .../src/services/gemini-service.ts | 674 +------------ .../src/services/model-stream-service.ts | 578 ++++++++++++ .../src/services/processor-service.ts | 544 ++++++----- .../src/services/pull-request-service.ts | 295 +++--- src/prompts/nitro-back/AI.md | 1 + .../nitro-back/workitems/2025-06-08-test.md | 6 + 10 files changed, 2077 insertions(+), 1921 deletions(-) delete mode 100644 src/functions/prompts-to-test-spec/src/services/__tests__/gemini-service.test.ts create mode 100644 src/functions/prompts-to-test-spec/src/services/__tests__/model-stream-service.test.ts create mode 100644 src/functions/prompts-to-test-spec/src/services/model-stream-service.ts diff --git a/src/functions/prompts-to-test-spec/src/index.ts b/src/functions/prompts-to-test-spec/src/index.ts index 893f5ab..0156a03 100644 --- a/src/functions/prompts-to-test-spec/src/index.ts +++ b/src/functions/prompts-to-test-spec/src/index.ts @@ -1,43 +1,43 @@ import {CloudEvent, cloudEvent, http} from '@google-cloud/functions-framework'; -import { ProcessorService } from './services/processor-service'; -import { validateConfig, DRY_RUN_SKIP_GEMINI, DRY_RUN_SKIP_COMMITS } from './config'; -import { ProcessResult, HttpResponse, ProjectSummary } from './types'; +import {ProcessorService} from './services/processor-service'; +import {validateConfig, DRY_RUN_SKIP_GEMINI, DRY_RUN_SKIP_COMMITS} from './config'; +import {ProcessResult, HttpResponse, ProjectSummary} from './types'; // Validate configuration on startup try { - validateConfig(); + validateConfig(); } catch (error) { - console.error('Configuration error:', error instanceof Error ? error.message : String(error)); - // Don't throw here to allow the function to start, but it will fail when executed + console.error('Configuration error:', error instanceof Error ? error.message : String(error)); + // Don't throw here to allow the function to start, but it will fail when executed } // Check if this is being run directly (via npm start) const isRunningDirectly = require.main === module; if (isRunningDirectly) { - console.log('Starting prompts-to-test-spec directly...'); + console.log('Starting prompts-to-test-spec directly...'); - // Log dry run status - if (DRY_RUN_SKIP_GEMINI) { - console.log('DRY RUN: Gemini API calls will be skipped'); - } - if (DRY_RUN_SKIP_COMMITS) { - console.log('DRY RUN: Commits and PRs will not be created'); - } - - // Run the processor - (async () => { - try { - const processor = new ProcessorService(); - console.log('Processing projects...'); - const results = await processor.processProjects(); - const formattedResults = formatHttpResponse(results); - console.log('Processing completed successfully'); - console.log('Results:', JSON.stringify(formattedResults, null, 2)); - } catch (error) { - console.error('Error processing projects:', error); - process.exit(1); + // Log dry run status + if (DRY_RUN_SKIP_GEMINI) { + console.log('DRY RUN: Gemini API calls will be skipped'); } - })(); + if (DRY_RUN_SKIP_COMMITS) { + console.log('DRY RUN: Commits and PRs will not be created'); + } + + // Run the processor + (async () => { + try { + const processor = new ProcessorService(); + console.log('Processing projects...'); + const results = await processor.processProjects(); + const formattedResults = formatHttpResponse(results); + console.log('Processing completed successfully'); + console.log('Results:', JSON.stringify(formattedResults, null, 2)); + } catch (error) { + console.error('Error processing projects:', error); + process.exit(1); + } + })(); } /** @@ -72,7 +72,6 @@ export function formatHttpResponse(results: ProcessResult[]): HttpResponse { workitemsCreated, filesWritten, pullRequestUrl: result.pullRequestUrl, - gitPatch: result.gitPatch }; }); diff --git a/src/functions/prompts-to-test-spec/src/services/__tests__/gemini-service.test.ts b/src/functions/prompts-to-test-spec/src/services/__tests__/gemini-service.test.ts deleted file mode 100644 index 3c1c0d0..0000000 --- a/src/functions/prompts-to-test-spec/src/services/__tests__/gemini-service.test.ts +++ /dev/null @@ -1,435 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { GeminiService } from '../gemini-service'; -import { GeminiProjectProcessor } from '../gemini-project-processor'; -import { Project } from '../../types'; - -// Mock dependencies -jest.mock('@google-cloud/vertexai'); -jest.mock('fs'); -jest.mock('path'); -jest.mock('../../config', () => ({ - GOOGLE_CLOUD_PROJECT_ID: 'mock-project-id', - GOOGLE_CLOUD_LOCATION: 'mock-location', - GEMINI_MODEL: 'mock-model', - DRY_RUN_SKIP_GEMINI: false -})); - -describe('GeminiService', () => { - let geminiService: GeminiService; - let mockGeminiProjectProcessor: jest.Mocked; - let mockVertexAI: any; - let mockGenerativeModel: any; - let mockChat: any; - - beforeEach(() => { - jest.clearAllMocks(); - - // Create a new instance of GeminiService - geminiService = new GeminiService(); - - // Mock GeminiProjectProcessor - mockGeminiProjectProcessor = { - getFileContent: jest.fn(), - writeFileContent: jest.fn(), - fileExists: jest.fn(), - listFiles: jest.fn(), - grepFiles: jest.fn(), - deleteFile: jest.fn(), - getCurrentWorkitem: jest.fn().mockReturnValue(null), - project: {} as Project, - projectRepoPath: '/mock/project/repo', - mainRepoPath: '/mock/main/repo', - processProject: jest.fn(), - processWorkitem: jest.fn(), - generateFeatureFile: jest.fn(), - collectRelevantFiles: jest.fn(), - matchesPattern: jest.fn() - } as unknown as jest.Mocked; - - // Mock VertexAI and its methods - mockChat = { - sendMessage: jest.fn() - }; - - mockGenerativeModel = { - startChat: jest.fn().mockReturnValue(mockChat) - }; - - mockVertexAI = { - getGenerativeModel: jest.fn().mockReturnValue(mockGenerativeModel) - }; - - // Replace the VertexAI instance in GeminiService with our mock - (geminiService as any).vertexAI = mockVertexAI; - }); - - describe('processFunctionCalls', () => { - it('should process getFileContent function call correctly', async () => { - // Setup mock responses - const mockResult = { - functionCalls: [ - { - name: 'getFileContent', - args: JSON.stringify({ filePath: 'test/file.txt' }) - } - ], - text: jest.fn().mockReturnValue('Initial response') - }; - - const mockNextResult = { - functionCalls: [], - text: jest.fn().mockReturnValue('Final response after function call') - }; - - // Setup mock implementations - mockGeminiProjectProcessor.getFileContent.mockReturnValue('File content'); - mockChat.sendMessage.mockResolvedValue(mockNextResult); - - // Call the method - const result = await (geminiService as any).processFunctionCalls( - mockResult, - mockChat, - mockGeminiProjectProcessor - ); - - // Verify the result - expect(result).toBe('Final response after function call'); - - // Verify the function was called with correct arguments - expect(mockGeminiProjectProcessor.getFileContent).toHaveBeenCalledWith('test/file.txt'); - - // Verify the chat.sendMessage was called with correct arguments - expect(mockChat.sendMessage).toHaveBeenCalledWith({ - functionResponse: { - name: 'getFileContent', - response: { result: JSON.stringify('File content') } - } - }); - }); - - it('should process writeFileContent function call correctly', async () => { - // Setup mock responses - const mockResult = { - functionCalls: [ - { - name: 'writeFileContent', - args: JSON.stringify({ - filePath: 'test/file.txt', - content: 'New content' - }) - } - ], - text: jest.fn().mockReturnValue('Initial response') - }; - - const mockNextResult = { - functionCalls: [], - text: jest.fn().mockReturnValue('Final response after function call') - }; - - // Setup mock implementations - mockGeminiProjectProcessor.writeFileContent.mockImplementation(() => {}); - mockChat.sendMessage.mockResolvedValue(mockNextResult); - - // Call the method - const result = await (geminiService as any).processFunctionCalls( - mockResult, - mockChat, - mockGeminiProjectProcessor - ); - - // Verify the result - expect(result).toBe('Final response after function call'); - - // Verify the function was called with correct arguments - expect(mockGeminiProjectProcessor.writeFileContent).toHaveBeenCalledWith( - 'test/file.txt', - 'New content', - undefined - ); - - // Verify the chat.sendMessage was called with correct arguments - expect(mockChat.sendMessage).toHaveBeenCalledWith({ - functionResponse: { - name: 'writeFileContent', - response: { result: JSON.stringify('File test/file.txt written successfully') } - } - }); - }); - - it('should process fileExists function call correctly', async () => { - // Setup mock responses - const mockResult = { - functionCalls: [ - { - name: 'fileExists', - args: JSON.stringify({ filePath: 'test/file.txt' }) - } - ], - text: jest.fn().mockReturnValue('Initial response') - }; - - const mockNextResult = { - functionCalls: [], - text: jest.fn().mockReturnValue('Final response after function call') - }; - - // Setup mock implementations - mockGeminiProjectProcessor.fileExists.mockReturnValue(true); - mockChat.sendMessage.mockResolvedValue(mockNextResult); - - // Call the method - const result = await (geminiService as any).processFunctionCalls( - mockResult, - mockChat, - mockGeminiProjectProcessor - ); - - // Verify the result - expect(result).toBe('Final response after function call'); - - // Verify the function was called with correct arguments - expect(mockGeminiProjectProcessor.fileExists).toHaveBeenCalledWith('test/file.txt'); - - // Verify the chat.sendMessage was called with correct arguments - expect(mockChat.sendMessage).toHaveBeenCalledWith({ - functionResponse: { - name: 'fileExists', - response: { result: JSON.stringify(true) } - } - }); - }); - - it('should process listFiles function call correctly', async () => { - // Setup mock responses - const mockResult = { - functionCalls: [ - { - name: 'listFiles', - args: JSON.stringify({ dirPath: 'test/dir' }) - } - ], - text: jest.fn().mockReturnValue('Initial response') - }; - - const mockNextResult = { - functionCalls: [], - text: jest.fn().mockReturnValue('Final response after function call') - }; - - // Setup mock implementations - mockGeminiProjectProcessor.listFiles.mockReturnValue(['file1.txt', 'file2.txt']); - mockChat.sendMessage.mockResolvedValue(mockNextResult); - - // Call the method - const result = await (geminiService as any).processFunctionCalls( - mockResult, - mockChat, - mockGeminiProjectProcessor - ); - - // Verify the result - expect(result).toBe('Final response after function call'); - - // Verify the function was called with correct arguments - expect(mockGeminiProjectProcessor.listFiles).toHaveBeenCalledWith('test/dir'); - - // Verify the chat.sendMessage was called with correct arguments - expect(mockChat.sendMessage).toHaveBeenCalledWith({ - functionResponse: { - name: 'listFiles', - response: { result: JSON.stringify(['file1.txt', 'file2.txt']) } - } - }); - }); - - it('should process grepFiles function call correctly', async () => { - // Setup mock responses - const mockResult = { - functionCalls: [ - { - name: 'grepFiles', - args: JSON.stringify({ - searchString: 'test', - filePattern: '*.txt' - }) - } - ], - text: jest.fn().mockReturnValue('Initial response') - }; - - const mockNextResult = { - functionCalls: [], - text: jest.fn().mockReturnValue('Final response after function call') - }; - - // Setup mock implementations - const grepResults = [ - { file: 'file1.txt', line: 10, content: 'test content' }, - { file: 'file2.txt', line: 20, content: 'more test content' } - ]; - mockGeminiProjectProcessor.grepFiles.mockReturnValue(grepResults); - mockChat.sendMessage.mockResolvedValue(mockNextResult); - - // Call the method - const result = await (geminiService as any).processFunctionCalls( - mockResult, - mockChat, - mockGeminiProjectProcessor - ); - - // Verify the result - expect(result).toBe('Final response after function call'); - - // Verify the function was called with correct arguments - expect(mockGeminiProjectProcessor.grepFiles).toHaveBeenCalledWith('test', '*.txt'); - - // Verify the chat.sendMessage was called with correct arguments - expect(mockChat.sendMessage).toHaveBeenCalledWith({ - functionResponse: { - name: 'grepFiles', - response: { result: JSON.stringify(grepResults) } - } - }); - }); - - it('should handle errors in function calls', async () => { - // Setup mock responses - const mockResult = { - functionCalls: [ - { - name: 'getFileContent', - args: JSON.stringify({ filePath: 'test/file.txt' }) - } - ], - text: jest.fn().mockReturnValue('Initial response') - }; - - const mockNextResult = { - functionCalls: [], - text: jest.fn().mockReturnValue('Final response after error') - }; - - // Setup mock implementations to throw an error - mockGeminiProjectProcessor.getFileContent.mockImplementation(() => { - throw new Error('File not found'); - }); - mockChat.sendMessage.mockResolvedValue(mockNextResult); - - // Call the method - const result = await (geminiService as any).processFunctionCalls( - mockResult, - mockChat, - mockGeminiProjectProcessor - ); - - // Verify the result - expect(result).toBe('Final response after error'); - - // Verify the function was called with correct arguments - expect(mockGeminiProjectProcessor.getFileContent).toHaveBeenCalledWith('test/file.txt'); - - // Verify the chat.sendMessage was called with correct arguments - expect(mockChat.sendMessage).toHaveBeenCalledWith({ - functionResponse: { - name: 'getFileContent', - response: { error: 'File not found' } - } - }); - }); - - it('should handle unknown function calls', async () => { - // Setup mock responses - const mockResult = { - functionCalls: [ - { - name: 'unknownFunction', - args: JSON.stringify({ param: 'value' }) - } - ], - text: jest.fn().mockReturnValue('Initial response') - }; - - const mockNextResult = { - functionCalls: [], - text: jest.fn().mockReturnValue('Final response after error') - }; - - // Setup mock implementations - mockChat.sendMessage.mockResolvedValue(mockNextResult); - - // Call the method - const result = await (geminiService as any).processFunctionCalls( - mockResult, - mockChat, - mockGeminiProjectProcessor - ); - - // Verify the result - expect(result).toBe('Final response after error'); - - // Verify the chat.sendMessage was called with correct arguments - expect(mockChat.sendMessage).toHaveBeenCalledWith({ - functionResponse: { - name: 'unknownFunction', - response: { error: 'Unknown function: unknownFunction' } - } - }); - }); - - it('should return text response if no function calls', async () => { - // Setup mock responses - const mockResult = { - functionCalls: [], - text: jest.fn().mockReturnValue('Text response') - }; - - // Call the method - const result = await (geminiService as any).processFunctionCalls( - mockResult, - mockChat, - mockGeminiProjectProcessor - ); - - // Verify the result - expect(result).toBe('Text response'); - - // Verify no function calls were made - expect(mockGeminiProjectProcessor.getFileContent).not.toHaveBeenCalled(); - expect(mockGeminiProjectProcessor.writeFileContent).not.toHaveBeenCalled(); - expect(mockGeminiProjectProcessor.fileExists).not.toHaveBeenCalled(); - expect(mockGeminiProjectProcessor.listFiles).not.toHaveBeenCalled(); - expect(mockGeminiProjectProcessor.grepFiles).not.toHaveBeenCalled(); - - // Verify no chat messages were sent - expect(mockChat.sendMessage).not.toHaveBeenCalled(); - }); - - it('should return text response if no geminiProjectProcessor provided', async () => { - // Setup mock responses - const mockResult = { - functionCalls: [ - { - name: 'getFileContent', - args: JSON.stringify({ filePath: 'test/file.txt' }) - } - ], - text: jest.fn().mockReturnValue('Text response') - }; - - // Call the method without providing geminiProjectProcessor - const result = await (geminiService as any).processFunctionCalls( - mockResult, - mockChat, - undefined - ); - - // Verify the result - expect(result).toBe('Text response'); - - // Verify no chat messages were sent - expect(mockChat.sendMessage).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/functions/prompts-to-test-spec/src/services/__tests__/model-stream-service.test.ts b/src/functions/prompts-to-test-spec/src/services/__tests__/model-stream-service.test.ts new file mode 100644 index 0000000..682e244 --- /dev/null +++ b/src/functions/prompts-to-test-spec/src/services/__tests__/model-stream-service.test.ts @@ -0,0 +1,517 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ModelStreamService } from '../model-stream-service'; +import { GeminiProjectProcessor } from '../gemini-project-processor'; +import { Workitem } from '../../types'; + +// Mock dependencies +jest.mock('fs'); +jest.mock('path'); +jest.mock('../../config', () => ({ + get GOOGLE_CLOUD_PROJECT_ID() { return process.env.GOOGLE_CLOUD_PROJECT_ID || 'mock-project-id'; }, + get GOOGLE_CLOUD_LOCATION() { return process.env.GOOGLE_CLOUD_LOCATION || 'mock-location'; }, + get GEMINI_MODEL() { return process.env.GEMINI_MODEL || 'mock-model'; }, + DRY_RUN_SKIP_GEMINI: false +})); + +// Mock VertexAI +const mockGenerateContentStream = jest.fn(); +const mockGenerateContent = jest.fn(); +const mockGetGenerativeModel = jest.fn().mockReturnValue({ + generateContentStream: mockGenerateContentStream, + generateContent: mockGenerateContent, + startChat: jest.fn() +}); + +jest.mock('@google-cloud/vertexai', () => { + return { + VertexAI: jest.fn().mockImplementation(() => { + return { + getGenerativeModel: mockGetGenerativeModel + }; + }), + FunctionDeclarationSchemaType: { + OBJECT: 'OBJECT', + STRING: 'STRING' + } + }; +}); + +describe('ModelStreamService', () => { + let modelStreamService: ModelStreamService; + let mockGeminiProjectProcessor: jest.Mocked; + let mockWorkitem: Workitem; + + beforeEach(() => { + jest.clearAllMocks(); + + // Reset all mocks + mockGenerateContentStream.mockReset(); + mockGenerateContent.mockReset(); + mockGetGenerativeModel.mockReset(); + + // Mock config values + process.env.GOOGLE_CLOUD_PROJECT_ID = 'mock-project-id'; + process.env.GOOGLE_CLOUD_LOCATION = 'mock-location'; + process.env.GEMINI_MODEL = 'mock-model'; + + // Mock workitem + mockWorkitem = { + name: 'test-workitem', + path: '/mock/path/to/workitem.md', + title: 'Test Workitem', + description: 'This is a test workitem', + isActive: true + }; + + // Mock GeminiProjectProcessor + mockGeminiProjectProcessor = { + getFileContent: jest.fn(), + writeFileContent: jest.fn(), + fileExists: jest.fn(), + listFiles: jest.fn(), + grepFiles: jest.fn(), + deleteFile: jest.fn(), + getCurrentWorkitem: jest.fn().mockReturnValue(mockWorkitem), + processProject: jest.fn(), + processWorkitem: jest.fn(), + generateFeatureFile: jest.fn(), + collectRelevantFiles: jest.fn(), + matchesPattern: jest.fn() + } as unknown as jest.Mocked; + + // Set up default mock behavior for generateContentStream + mockGetGenerativeModel.mockReturnValue({ + generateContentStream: mockGenerateContentStream, + generateContent: mockGenerateContent + }); + + // Create a new instance of ModelStreamService + modelStreamService = new ModelStreamService( + mockGeminiProjectProcessor, + mockWorkitem + ); + }); + + describe('processModelStream', () => { + it('should process model stream and handle function calls', async () => { + // Set up the mock response for the initial stream + const initialStreamResponse = { + stream: [ + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'getFileContent', + args: JSON.stringify({ filePath: 'test/file.txt' }) + } + } + ] + } + } + ] + } + ] + }; + + // Set up the mock response for the next stream after function call + const nextStreamResponse = { + stream: [ + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'writeFileContent', + args: JSON.stringify({ + filePath: 'test/output.txt', + content: 'Test content' + }) + } + } + ] + } + } + ] + } + ] + }; + + // Set up the mock response for the final stream with decision + const finalStreamResponse = { + stream: [ + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'makeDecision', + args: JSON.stringify({ + decision: 'create', + reason: 'Created a new file' + }) + } + } + ] + } + } + ] + }, + { + candidates: [ + { + content: { + parts: [ + { text: 'Processing complete' } + ] + } + } + ] + } + ] + }; + + // Set up the mock implementations + mockGenerateContentStream + .mockResolvedValueOnce(initialStreamResponse) + .mockResolvedValueOnce(nextStreamResponse) + .mockResolvedValueOnce(finalStreamResponse) + .mockResolvedValue({ stream: [] }); // Add a default empty stream for any additional calls + + mockGeminiProjectProcessor.getFileContent.mockReturnValue('Mock file content'); + + // Call the method + const result = await modelStreamService.processModelStream( + 'Test guidelines', + 'Test workitem content' + ); + + // Verify the result + expect(result.text).toContain('Processing complete'); + expect(result.decision).toBeDefined(); + expect(result.decision?.decision).toBe('create'); + expect(result.decision?.reason).toBe('Created a new file'); + expect(result.filesWritten).toContain('test/output.txt'); + expect(result.filesDeleted).toHaveLength(0); + + // Verify the function calls + expect(mockGeminiProjectProcessor.getFileContent).toHaveBeenCalledWith('test/file.txt'); + expect(mockGeminiProjectProcessor.writeFileContent).toHaveBeenCalledWith( + 'test/output.txt', + 'Test content', + 'test-workitem' + ); + + // Verify the generateContentStream was called + expect(mockGenerateContentStream).toHaveBeenCalledTimes(4); + }); + + it('should handle file deletion', async () => { + // Set up the mock response with a deleteFile function call + const streamResponse = { + stream: [ + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'deleteFile', + args: JSON.stringify({ filePath: 'test/file-to-delete.txt' }) + } + } + ] + } + } + ] + } + ] + }; + + // Set up the mock response for the next stream after function call + const nextStreamResponse = { + stream: [ + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'makeDecision', + args: JSON.stringify({ + decision: 'delete', + reason: 'Deleted a file' + }) + } + } + ] + } + } + ] + }, + { + candidates: [ + { + content: { + parts: [ + { text: 'Deletion complete' } + ] + } + } + ] + } + ] + }; + + // Set up the mock implementations + mockGenerateContentStream + .mockResolvedValueOnce(streamResponse) + .mockResolvedValueOnce(nextStreamResponse) + .mockResolvedValue({ stream: [] }); // Add a default empty stream for any additional calls + + mockGeminiProjectProcessor.deleteFile.mockReturnValue('File test/file-to-delete.txt deleted successfully'); + + // Call the method + const result = await modelStreamService.processModelStream( + 'Test guidelines', + 'Test workitem content' + ); + + // Verify the result + expect(result.text).toContain('Deletion complete'); + expect(result.decision).toBeDefined(); + expect(result.decision?.decision).toBe('delete'); + expect(result.decision?.reason).toBe('Deleted a file'); + expect(result.filesWritten).toHaveLength(0); + expect(result.filesDeleted).toContain('test/file-to-delete.txt'); + + // Verify the function calls + expect(mockGeminiProjectProcessor.deleteFile).toHaveBeenCalledWith('test/file-to-delete.txt'); + + // Verify the generateContentStream was called + expect(mockGenerateContentStream).toHaveBeenCalledTimes(3); + }); + + it('should handle errors in function calls', async () => { + // Set up the mock response with a function call that will fail + const streamResponse = { + stream: [ + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'getFileContent', + args: JSON.stringify({ filePath: 'test/non-existent-file.txt' }) + } + } + ] + } + } + ] + } + ] + }; + + // Set up the mock response for the next stream after error + const nextStreamResponse = { + stream: [ + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'makeDecision', + args: JSON.stringify({ + decision: 'skip', + reason: 'File not found' + }) + } + } + ] + } + } + ] + }, + { + candidates: [ + { + content: { + parts: [ + { text: 'Error handled' } + ] + } + } + ] + } + ] + }; + + // Set up the mock implementations + mockGenerateContentStream + .mockResolvedValueOnce(streamResponse) + .mockResolvedValueOnce(nextStreamResponse) + .mockResolvedValue({ stream: [] }); // Add a default empty stream for any additional calls + + mockGeminiProjectProcessor.getFileContent.mockImplementation(() => { + throw new Error('File not found'); + }); + + // Call the method + const result = await modelStreamService.processModelStream( + 'Test guidelines', + 'Test workitem content' + ); + + // Verify the result + expect(result.text).toContain('Error handled'); + expect(result.decision).toBeDefined(); + expect(result.decision?.decision).toBe('skip'); + expect(result.decision?.reason).toBe('File not found'); + expect(result.filesWritten).toHaveLength(0); + expect(result.filesDeleted).toHaveLength(0); + + // Verify the function calls + expect(mockGeminiProjectProcessor.getFileContent).toHaveBeenCalledWith('test/non-existent-file.txt'); + + // Verify the generateContentStream was called + expect(mockGenerateContentStream).toHaveBeenCalledTimes(3); + }); + + it('should parse decision from text if no makeDecision function call', async () => { + // Set up the mock response with text containing a JSON decision + const streamResponse = { + stream: [ + { + candidates: [ + { + content: { + parts: [ + { + text: 'Some text before the decision { "decision": "skip", "reason": "No changes needed" } Some text after' + } + ] + } + } + ] + } + ] + }; + + // Set up the mock implementations + mockGenerateContentStream + .mockResolvedValueOnce(streamResponse) + .mockResolvedValue({ stream: [] }); // Add a default empty stream for any additional calls + + // Call the method + const result = await modelStreamService.processModelStream( + 'Test guidelines', + 'Test workitem content' + ); + + // Verify the result + expect(result.text).toContain('Some text before the decision'); + expect(result.decision).toBeDefined(); + expect(result.decision?.decision).toBe('skip'); + expect(result.decision?.reason).toBe('No changes needed'); + expect(result.filesWritten).toHaveLength(0); + expect(result.filesDeleted).toHaveLength(0); + + // Verify the generateContentStream was called + expect(mockGenerateContentStream).toHaveBeenCalledTimes(1); + }); + + it('should handle dry run mode', async () => { + // Create a new service instance with dryRun set to true + const dryRunService = new ModelStreamService( + mockGeminiProjectProcessor, + mockWorkitem, + undefined, // projectId + undefined, // location + undefined, // model + true // dryRun + ); + + // Call the method + const result = await dryRunService.processModelStream( + 'Test guidelines', + 'Test workitem content' + ); + + // Verify the result + expect(result.text).toContain('DRY RUN'); + expect(result.decision).toBeDefined(); + expect(result.decision?.decision).toBe('create'); + expect(result.decision?.reason).toBe('This is a mock decision for dry run mode'); + expect(result.filesWritten).toHaveLength(0); + expect(result.filesDeleted).toHaveLength(0); + + // Verify the generateContentStream was not called + expect(mockGenerateContentStream).not.toHaveBeenCalled(); + }); + }); + + describe('getModelResponses', () => { + it('should return all model responses', async () => { + // Set up the mock response with text + const streamResponse = { + stream: [ + { + candidates: [ + { + content: { + parts: [ + { text: 'Response 1' } + ] + } + } + ] + }, + { + candidates: [ + { + content: { + parts: [ + { text: 'Response 2' } + ] + } + } + ] + } + ] + }; + + // Set up the mock implementations + mockGenerateContentStream + .mockResolvedValueOnce(streamResponse) + .mockResolvedValue({ stream: [] }); // Add a default empty stream for any additional calls + + // Call the method + await modelStreamService.processModelStream( + 'Test guidelines', + 'Test workitem content' + ); + + // Get the model responses + const responses = modelStreamService.getModelResponses(); + + // Verify the responses + expect(responses).toHaveLength(2); + expect(responses[0]).toBe('Response 1'); + expect(responses[1]).toBe('Response 2'); + }); + }); +}); diff --git a/src/functions/prompts-to-test-spec/src/services/gemini-project-processor.ts b/src/functions/prompts-to-test-spec/src/services/gemini-project-processor.ts index 35c300c..7e84911 100644 --- a/src/functions/prompts-to-test-spec/src/services/gemini-project-processor.ts +++ b/src/functions/prompts-to-test-spec/src/services/gemini-project-processor.ts @@ -3,250 +3,293 @@ */ import * as fs from 'fs'; import * as path from 'path'; -import { Project, Workitem, ProcessResult } from '../types'; -import { GeminiService } from './gemini-service'; -import { ProjectService } from './project-service'; -import { RepositoryService } from './repository-service'; -import { DRY_RUN_SKIP_GEMINI } from '../config'; +import {ProcessResult, Project, Workitem} from '../types'; +import {ProjectService} from './project-service'; +import {RepositoryService} from './repository-service'; +import {DRY_RUN_SKIP_GEMINI} from '../config'; +import {ModelStreamService} from './model-stream-service'; export class GeminiProjectProcessor { - private geminiService: GeminiService; - private projectService: ProjectService; - private repositoryService: RepositoryService; - private project: Project; - private projectRepoPath: string; - private mainRepoPath: string; - private filesWritten: Map = new Map(); // Map of workitem name to files written - private currentWorkitem: Workitem | null = null; // Track the current workitem being processed + private projectService: ProjectService; + private repositoryService: RepositoryService; + private project: Project; + private projectRepoPath: string; + private filesWritten: Map = new Map(); // Map of workitem name to files written + private currentWorkitem: Workitem | null = null; // Track the current workitem being processed - constructor( - project: Project, - projectRepoPath: string, - mainRepoPath: string - ) { - this.project = project; - this.projectRepoPath = projectRepoPath; - this.mainRepoPath = mainRepoPath; - this.geminiService = new GeminiService(); - this.projectService = new ProjectService(); - this.repositoryService = new RepositoryService(); - } + constructor( + project: Project, + projectRepoPath: string, + mainRepoPath: string + ) { + this.project = project; + this.projectRepoPath = projectRepoPath; + this.projectService = new ProjectService(); + this.repositoryService = new RepositoryService(); + } - /** - * Process the project using Gemini - * @returns Process result - */ - async processProject(): Promise { - console.log(`GeminiProjectProcessor: Processing project ${this.project.name}`); + /** + * Process the project using Gemini + * @returns Process result + */ + async processProject(): Promise { + console.log(`GeminiProjectProcessor: Processing project ${this.project.name}`); - try { - // Find all workitems in the project - const workitems = await this.projectService.findWorkitems(this.project.path); - console.log(`GeminiProjectProcessor: Found ${workitems.length} workitems in project ${this.project.name}`); - - // Skip if no workitems found - if (workitems.length === 0) { - return { - project: this.project, - processedWorkitems: [] - }; - } - - // Read project guidelines - const projectGuidelines = await this.projectService.readProjectGuidelines(this.project.path); - - // Process each workitem - const processedWorkitems = []; - for (const workitem of workitems) { - console.log(`GeminiProjectProcessor: Processing workitem: ${workitem.name}`); - const result = await this.processWorkitem(workitem, projectGuidelines); - processedWorkitems.push({ workitem, ...result }); - } - - // Generate git patch if any files were written - let gitPatch: string | undefined = undefined; - const totalFilesWritten = processedWorkitems.reduce((total, item) => total + (item.filesWritten?.length || 0), 0); - - if (totalFilesWritten > 0) { try { - console.log(`Generating git patch for project ${this.project.name} with ${totalFilesWritten} files written`); - gitPatch = await this.repositoryService.generateGitPatch(this.projectRepoPath); + // Find all workitems in the project + const workitems = await this.projectService.findWorkitems(this.project.path); + console.log(`GeminiProjectProcessor: Found ${workitems.length} workitems in project ${this.project.name}`); + + // Skip if no workitems found + if (workitems.length === 0) { + return { + project: this.project, + processedWorkitems: [] + }; + } + + // Read project guidelines + const projectGuidelines = await this.projectService.readProjectGuidelines(this.project.path); + + // Process each workitem + const processedWorkitems = []; + for (const workitem of workitems) { + console.log(`GeminiProjectProcessor: Processing workitem: ${workitem.name}`); + const result = await this.processWorkitem(workitem, projectGuidelines); + processedWorkitems.push({workitem, ...result}); + } + + // Generate git patch if any files were written + let gitPatch: string | undefined = undefined; + const totalFilesWritten = processedWorkitems.reduce((total, item) => total + (item.filesWritten?.length || 0), 0); + + if (totalFilesWritten > 0) { + try { + console.log(`Generating git patch for project ${this.project.name} with ${totalFilesWritten} files written`); + gitPatch = await this.repositoryService.generateGitPatch(this.projectRepoPath); + } catch (error) { + console.error(`Error generating git patch for project ${this.project.name}:`, error); + } + } + + return { + project: this.project, + processedWorkitems, + gitPatch + }; } catch (error) { - console.error(`Error generating git patch for project ${this.project.name}:`, error); + console.error(`Error processing project ${this.project.name}:`, error); + return { + project: this.project, + processedWorkitems: [], + error: error instanceof Error ? error.message : String(error) + }; } - } - - return { - project: this.project, - processedWorkitems, - gitPatch - }; - } catch (error) { - console.error(`Error processing project ${this.project.name}:`, error); - return { - project: this.project, - processedWorkitems: [], - error: error instanceof Error ? error.message : String(error) - }; } - } - /** - * Get the current workitem being processed - * @returns The current workitem or null if no workitem is being processed - */ - getCurrentWorkitem(): Workitem | null { - return this.currentWorkitem; - } + /** + * Get the current workitem being processed + * @returns The current workitem or null if no workitem is being processed + */ + getCurrentWorkitem(): Workitem | null { + return this.currentWorkitem; + } - /** - * Process a workitem using Gemini - * @param workitem Workitem to process - * @param projectGuidelines Project guidelines - * @returns Result of the processing - */ - private async processWorkitem( - workitem: Workitem, - projectGuidelines: string - ): Promise<{ success: boolean; error?: string; status?: 'skipped' | 'updated' | 'created'; filesWritten?: string[] }> { - try { - // Set the current workitem - this.currentWorkitem = workitem; - console.log(`GeminiProjectProcessor: Processing workitem: ${workitem.name} (Active: ${workitem.isActive})`); - - // Initialize tracking for this workitem - this.filesWritten.set(workitem.name, []); - - // Determine initial status based on workitem activity - let status: 'skipped' | 'updated' | 'created' = 'skipped'; - - // Read workitem content - const workitemContent = fs.readFileSync(workitem.path, 'utf-8'); - - // Collect all relevant files from the project directory - const relevantFiles = await this.collectRelevantFiles(workitem); - - // Let Gemini decide what to do with the workitem - const result = await this.generateFeatureFile( - projectGuidelines, - workitemContent, - workitem.name, - relevantFiles - ); - - // Get the list of files written for this workitem - const filesWritten = this.filesWritten.get(workitem.name) || []; - - // If no files were written, but the workitem is active, consider it skipped - if (filesWritten.length === 0) { - status = 'skipped'; - } - - // Update the workitem file with implementation log - if (status !== 'skipped') { + /** + * Process a workitem using Gemini + * @param workitem Workitem to process + * @param projectGuidelines Project guidelines + * @returns Result of the processing + */ + private async processWorkitem( + workitem: Workitem, + projectGuidelines: string + ): Promise<{ + success: boolean; + error?: string; + status?: 'skipped' | 'updated' | 'created'; + filesWritten?: string[] + }> { try { - // Determine the log status based on the operation status - const logStatus = status === 'created' ? 'created' : - (status === 'updated' ? 'updated' : 'deleted'); + // Set the current workitem + this.currentWorkitem = workitem; + console.log(`GeminiProjectProcessor: Processing workitem: ${workitem.name} (Active: ${workitem.isActive})`); - // Get the list of files without the "deleted:" prefix for deleted files - const filesList = filesWritten.map(file => - file.startsWith('deleted:') ? file.substring(8) : file - ); + // Initialize tracking for this workitem + this.filesWritten.set(workitem.name, []); - // Update the workitem file with implementation log - await this.projectService.updateWorkitemWithImplementationLog( - workitem, - logStatus, - filesList - ); + // Determine initial status based on workitem activity + let status: 'skipped' | 'updated' | 'created' = 'skipped'; - console.log(`GeminiProjectProcessor: Updated workitem file with implementation log for ${workitem.name}`); + // Read workitem content + const workitemContent = fs.readFileSync(workitem.path, 'utf-8'); + + // Collect all relevant files from the project directory + const relevantFiles = await this.collectRelevantFiles(workitem); + + // Let Gemini decide what to do with the workitem + const result = await this.generateFeatureFile( + projectGuidelines, + workitemContent, + workitem.name, + relevantFiles + ); + + // Track files written and deleted from the ModelStreamService result + const filesWritten = [...result.filesWritten]; + const filesDeleted = result.filesDeleted; + + // Add deleted files to the tracking with the "deleted:" prefix + filesDeleted.forEach(file => { + filesWritten.push(`deleted:${file}`); + }); + + // Update the filesWritten map + this.filesWritten.set(workitem.name, filesWritten); + + // Use the model's decision to determine the status if available + if (result.decision) { + console.log(`Using model decision: ${result.decision.decision} for workitem ${workitem.name}`); + + // Update status based on the model's decision + switch (result.decision.decision) { + case 'create': + status = 'created'; + break; + case 'update': + status = 'updated'; + break; + case 'delete': + // Keep status as 'skipped' for delete if no files were actually deleted + if (filesDeleted.length > 0) { + status = 'updated'; // We use 'updated' for deletions too + } + break; + case 'skip': + status = 'skipped'; + break; + } + } else { + // Fallback to the old behavior if no decision is available + // If no files were written or deleted, consider it skipped + if (filesWritten.length === 0 && filesDeleted.length === 0) { + status = 'skipped'; + } else if (filesWritten.length > 0 || filesDeleted.length > 0) { + // If files were written or deleted, consider it created/updated + status = filesWritten.length > 0 ? 'created' : 'updated'; + } + } + + // Update the workitem file with implementation log + if (status !== 'skipped') { + try { + // Determine the log status based on the operation status + const logStatus = status === 'created' ? 'created' : + (status === 'updated' ? 'updated' : 'deleted'); + + // Get the list of files without the "deleted:" prefix for deleted files + const filesList = filesWritten.map(file => + file.startsWith('deleted:') ? file.substring(8) : file + ); + + // Update the workitem file with implementation log + await this.projectService.updateWorkitemWithImplementationLog( + workitem, + logStatus, + filesList + ); + + console.log(`GeminiProjectProcessor: Updated workitem file with implementation log for ${workitem.name}`); + } catch (error) { + console.error(`Error updating workitem file with implementation log: ${error}`); + } + } + + console.log(`GeminiProjectProcessor: Completed processing workitem: ${workitem.name} (Status: ${status}, Files written: ${filesWritten.length})`); + return { + success: true, + status, + filesWritten + }; } catch (error) { - console.error(`Error updating workitem file with implementation log: ${error}`); + console.error(`Error processing workitem ${workitem.name}:`, error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + status: 'skipped', + filesWritten: [] + }; } - } - - console.log(`GeminiProjectProcessor: Completed processing workitem: ${workitem.name} (Status: ${status}, Files written: ${filesWritten.length})`); - return { - success: true, - status, - filesWritten - }; - } catch (error) { - console.error(`Error processing workitem ${workitem.name}:`, error); - return { - success: false, - error: error instanceof Error ? error.message : String(error), - status: 'skipped', - filesWritten: [] - }; - } - } - - /** - * Collect relevant files from the project directory - * @param workitem The workitem being processed - * @returns Object containing file contents - */ - private async collectRelevantFiles(workitem: Workitem): Promise> { - const relevantFiles: Record = {}; - - try { - // Get the project directory path - const projectDir = path.dirname(path.dirname(workitem.path)); // workitem.path -> workitems/name.md -> project/ - - // Check for INFO.md - const infoPath = path.join(projectDir, 'INFO.md'); - if (fs.existsSync(infoPath)) { - relevantFiles['INFO.md'] = fs.readFileSync(infoPath, 'utf-8'); - } - - // AI.md is already included in the main prompt - - // Check for other potentially relevant files - const potentialFiles = [ - 'README.md', - 'GUIDELINES.md', - 'ARCHITECTURE.md', - 'IMPLEMENTATION.md' - ]; - - for (const file of potentialFiles) { - const filePath = path.join(projectDir, file); - if (fs.existsSync(filePath)) { - relevantFiles[file] = fs.readFileSync(filePath, 'utf-8'); - } - } - - console.log(`GeminiProjectProcessor: Collected ${Object.keys(relevantFiles).length} relevant files for workitem ${workitem.name}`); - } catch (error) { - console.error(`Error collecting relevant files for workitem ${workitem.name}:`, error); } - return relevantFiles; - } + /** + * Collect relevant files from the project directory + * @param workitem The workitem being processed + * @returns Object containing file contents + */ + private async collectRelevantFiles(workitem: Workitem): Promise> { + const relevantFiles: Record = {}; - /** - * Generate feature file content using Gemini API - * @param guidelines Project guidelines - * @param workitemContent Workitem content - * @param workitemName Name of the workitem - * @param relevantFiles Additional relevant files to include in the prompt - * @returns Generated feature file content - */ - private async generateFeatureFile( - guidelines: string, - workitemContent: string, - workitemName: string, - relevantFiles: Record = {} - ): Promise { - const currentDate = new Date().toISOString(); + try { + // Get the project directory path + const projectDir = path.dirname(path.dirname(workitem.path)); // workitem.path -> workitems/name.md -> project/ - // If dry run is enabled, return a mock feature file - if (DRY_RUN_SKIP_GEMINI) { - console.log(`[DRY RUN] Skipping Gemini API call for generating feature file for ${workitemName}`); - return `# Generated by prompts-to-test-spec on ${currentDate} (DRY RUN) + // Check for INFO.md + const infoPath = path.join(projectDir, 'INFO.md'); + if (fs.existsSync(infoPath)) { + relevantFiles['INFO.md'] = fs.readFileSync(infoPath, 'utf-8'); + } + + // AI.md is already included in the main prompt + + // Check for other potentially relevant files + const potentialFiles = [ + 'README.md', + 'GUIDELINES.md', + 'ARCHITECTURE.md', + 'IMPLEMENTATION.md' + ]; + + for (const file of potentialFiles) { + const filePath = path.join(projectDir, file); + if (fs.existsSync(filePath)) { + relevantFiles[file] = fs.readFileSync(filePath, 'utf-8'); + } + } + + console.log(`GeminiProjectProcessor: Collected ${Object.keys(relevantFiles).length} relevant files for workitem ${workitem.name}`); + } catch (error) { + console.error(`Error collecting relevant files for workitem ${workitem.name}:`, error); + } + + return relevantFiles; + } + + /** + * Generate feature file content using Gemini API + * @param guidelines Project guidelines + * @param workitemContent Workitem content + * @param workitemName Name of the workitem + * @param relevantFiles Additional relevant files to include in the prompt + * @returns Object containing the generated text, parsed decision, and files written/deleted + */ + private async generateFeatureFile( + guidelines: string, + workitemContent: string, + workitemName: string, + relevantFiles: Record = {} + ): Promise<{ + text: string; + decision?: { decision: 'create' | 'update' | 'delete' | 'skip'; reason: string }; + filesWritten: string[]; + filesDeleted: string[]; + }> { + const currentDate = new Date().toISOString(); + + // If dry run is enabled, return a mock feature file + if (DRY_RUN_SKIP_GEMINI) { + console.log(`[DRY RUN] Skipping Gemini API call for generating feature file for ${workitemName}`); + const mockText = `# Generated by prompts-to-test-spec on ${currentDate} (DRY RUN) # Source: ${workitemName} Feature: ${workitemName} (DRY RUN) @@ -258,200 +301,226 @@ Feature: ${workitemName} (DRY RUN) When the feature file is generated Then a mock feature file is returned `; - } - - console.log(`Using function calling to generate feature file for ${workitemName}`); - - // Prepare additional context from relevant files - let additionalContext = ''; - for (const [filename, content] of Object.entries(relevantFiles)) { - additionalContext += `\n--- ${filename} ---\n${content}\n`; - } - - // Pass this instance as the processor to handle function calls - return await this.geminiService.generateFeatureFile( - guidelines, - workitemContent, - workitemName, - this, // Pass the GeminiProjectProcessor instance to handle function calls - additionalContext // Pass additional context from relevant files - ); - } - - /** - * Get the content of a file in the project repository - * @param filePath Path to the file relative to the project repository root - * @returns File content - */ - getFileContent(filePath: string): string { - const fullPath = path.join(this.projectRepoPath, filePath); - if (!fs.existsSync(fullPath)) { - throw new Error(`File not found: ${filePath}`); - } - return fs.readFileSync(fullPath, 'utf-8'); - } - - /** - * Write content to a file in the project repository - * @param filePath Path to the file relative to the project repository root - * @param content Content to write - * @param workitemName Optional name of the workitem being processed - */ - writeFileContent(filePath: string, content: string, workitemName?: string): void { - const fullPath = path.join(this.projectRepoPath, filePath); - const dirPath = path.dirname(fullPath); - - // Ensure directory exists - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - - fs.writeFileSync(fullPath, content, 'utf-8'); - - // Track the file operation if workitemName is provided - if (workitemName) { - if (!this.filesWritten.has(workitemName)) { - this.filesWritten.set(workitemName, []); - } - this.filesWritten.get(workitemName)!.push(filePath); - console.log(`Tracked file write for workitem ${workitemName}: ${filePath}`); - } - } - - /** - * Check if a file exists in the project repository - * @param filePath Path to the file relative to the project repository root - * @returns True if the file exists, false otherwise - */ - fileExists(filePath: string): boolean { - const fullPath = path.join(this.projectRepoPath, filePath); - return fs.existsSync(fullPath); - } - - /** - * Delete a file from the project repository - * @param filePath Path to the file relative to the project repository root - * @returns Message indicating success or that the file didn't exist - */ - deleteFile(filePath: string): string { - const fullPath = path.join(this.projectRepoPath, filePath); - - if (!fs.existsSync(fullPath)) { - return `File ${filePath} does not exist`; - } - - fs.unlinkSync(fullPath); - - // Track the file operation using the current workitem - const currentWorkitem = this.getCurrentWorkitem(); - if (currentWorkitem) { - const workitemName = currentWorkitem.name; - if (!this.filesWritten.has(workitemName)) { - this.filesWritten.set(workitemName, []); - } - // We're tracking deletions in the same array as writes, but with a "deleted:" prefix - this.filesWritten.get(workitemName)!.push(`deleted:${filePath}`); - console.log(`Tracked file deletion for workitem ${workitemName}: ${filePath}`); - } - - return `File ${filePath} deleted successfully`; - } - - /** - * List files in a directory in the project repository - * @param dirPath Path to the directory relative to the project repository root - * @returns Array of file names - */ - listFiles(dirPath: string): string[] { - const fullPath = path.join(this.projectRepoPath, dirPath); - if (!fs.existsSync(fullPath)) { - throw new Error(`Directory not found: ${dirPath}`); - } - return fs.readdirSync(fullPath); - } - - /** - * Search for a string in project files - * @param searchString String to search for - * @param filePattern Optional file pattern to limit the search (e.g., "*.ts", "src/*.java") - * @returns Array of matches with file paths and line numbers - */ - grepFiles(searchString: string, filePattern?: string): Array<{file: string, line: number, content: string}> { - console.log(`Searching for "${searchString}" in project files${filePattern ? ` matching ${filePattern}` : ''}`); - - if (!searchString) { - throw new Error('Search string is required'); - } - - const results: Array<{file: string, line: number, content: string}> = []; - - // Helper function to search in a file - const searchInFile = (filePath: string, relativePath: string) => { - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const lines = content.split('\n'); - - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes(searchString)) { - results.push({ - file: relativePath, - line: i + 1, // 1-based line numbers - content: lines[i].trim() - }); - } + return { + text: mockText, + decision: { + decision: 'create', + reason: 'This is a mock decision for dry run mode' + }, + filesWritten: [], + filesDeleted: [] + }; } - } catch (error) { - console.error(`Error searching in file ${filePath}:`, error); - } - }; - // Helper function to recursively search in a directory - const searchInDirectory = (dirPath: string, baseDir: string) => { - try { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + console.log(`Using function calling to generate feature file for ${workitemName}`); - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - const relativePath = path.relative(baseDir, fullPath); - - if (entry.isDirectory()) { - // Skip node_modules and .git directories - if (entry.name !== 'node_modules' && entry.name !== '.git') { - searchInDirectory(fullPath, baseDir); - } - } else if (entry.isFile()) { - // Check if the file matches the pattern - if (!filePattern || this.matchesPattern(entry.name, filePattern)) { - searchInFile(fullPath, relativePath); - } - } + // Prepare additional context from relevant files + let additionalContext = ''; + for (const [filename, content] of Object.entries(relevantFiles)) { + additionalContext += `\n--- ${filename} ---\n${content}\n`; } - } catch (error) { - console.error(`Error searching in directory ${dirPath}:`, error); - } - }; - // Start the search from the project repository root - searchInDirectory(this.projectRepoPath, this.projectRepoPath); + // Get the current workitem + const currentWorkitem = this.getCurrentWorkitem(); + if (!currentWorkitem) { + throw new Error(`No current workitem set for ${workitemName}`); + } - console.log(`Found ${results.length} matches for "${searchString}"`); - return results; - } + // Create a new ModelStreamService for this workitem + const modelStreamService = new ModelStreamService( + this, + currentWorkitem + ); - /** - * Check if a filename matches a simple pattern - * @param filename Filename to check - * @param pattern Pattern to match (supports * wildcard) - * @returns True if the filename matches the pattern - */ - private matchesPattern(filename: string, pattern: string): boolean { - // Convert the pattern to a regex - // Escape special regex characters except * - const regexPattern = pattern - .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex chars - .replace(/\*/g, '.*'); // Convert * to .* + // Process the model stream + const result = await modelStreamService.processModelStream( + guidelines, + workitemContent, + additionalContext + ); - const regex = new RegExp(`^${regexPattern}$`); - return regex.test(filename); - } + return { + text: result.text, + decision: result.decision, + filesWritten: result.filesWritten, + filesDeleted: result.filesDeleted + }; + } + + /** + * Get the content of a file in the project repository + * @param filePath Path to the file relative to the project repository root + * @returns File content + */ + getFileContent(filePath: string): string { + const fullPath = path.join(this.projectRepoPath, filePath); + if (!fs.existsSync(fullPath)) { + throw new Error(`File not found: ${filePath}`); + } + return fs.readFileSync(fullPath, 'utf-8'); + } + + /** + * Write content to a file in the project repository + * @param filePath Path to the file relative to the project repository root + * @param content Content to write + * @param workitemName Optional name of the workitem being processed + */ + writeFileContent(filePath: string, content: string, workitemName?: string): void { + const fullPath = path.join(this.projectRepoPath, filePath); + const dirPath = path.dirname(fullPath); + + // Ensure directory exists + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, {recursive: true}); + } + + fs.writeFileSync(fullPath, content, 'utf-8'); + + // Track the file operation if workitemName is provided + if (workitemName) { + if (!this.filesWritten.has(workitemName)) { + this.filesWritten.set(workitemName, []); + } + this.filesWritten.get(workitemName)!.push(filePath); + console.log(`Tracked file write for workitem ${workitemName}: ${filePath}`); + } + } + + /** + * Check if a file exists in the project repository + * @param filePath Path to the file relative to the project repository root + * @returns True if the file exists, false otherwise + */ + fileExists(filePath: string): boolean { + const fullPath = path.join(this.projectRepoPath, filePath); + return fs.existsSync(fullPath); + } + + /** + * Delete a file from the project repository + * @param filePath Path to the file relative to the project repository root + * @returns Message indicating success or that the file didn't exist + */ + deleteFile(filePath: string): string { + const fullPath = path.join(this.projectRepoPath, filePath); + + if (!fs.existsSync(fullPath)) { + return `File ${filePath} does not exist`; + } + + fs.unlinkSync(fullPath); + + // Track the file operation using the current workitem + const currentWorkitem = this.getCurrentWorkitem(); + if (currentWorkitem) { + const workitemName = currentWorkitem.name; + if (!this.filesWritten.has(workitemName)) { + this.filesWritten.set(workitemName, []); + } + // We're tracking deletions in the same array as writes, but with a "deleted:" prefix + this.filesWritten.get(workitemName)!.push(`deleted:${filePath}`); + console.log(`Tracked file deletion for workitem ${workitemName}: ${filePath}`); + } + + return `File ${filePath} deleted successfully`; + } + + /** + * List files in a directory in the project repository + * @param dirPath Path to the directory relative to the project repository root + * @returns Array of file names + */ + listFiles(dirPath: string): string[] { + const fullPath = path.join(this.projectRepoPath, dirPath); + if (!fs.existsSync(fullPath)) { + throw new Error(`Directory not found: ${dirPath}`); + } + return fs.readdirSync(fullPath); + } + + /** + * Search for a string in project files + * @param searchString String to search for + * @param filePattern Optional file pattern to limit the search (e.g., "*.ts", "src/*.java") + * @returns Array of matches with file paths and line numbers + */ + grepFiles(searchString: string, filePattern?: string): Array<{ file: string, line: number, content: string }> { + console.log(`Searching for "${searchString}" in project files${filePattern ? ` matching ${filePattern}` : ''}`); + + if (!searchString) { + throw new Error('Search string is required'); + } + + const results: Array<{ file: string, line: number, content: string }> = []; + + // Helper function to search in a file + const searchInFile = (filePath: string, relativePath: string) => { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(searchString)) { + results.push({ + file: relativePath, + line: i + 1, // 1-based line numbers + content: lines[i].trim() + }); + } + } + } catch (error) { + console.error(`Error searching in file ${filePath}:`, error); + } + }; + + // Helper function to recursively search in a directory + const searchInDirectory = (dirPath: string, baseDir: string) => { + try { + const entries = fs.readdirSync(dirPath, {withFileTypes: true}); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + const relativePath = path.relative(baseDir, fullPath); + + if (entry.isDirectory()) { + // Skip node_modules and .git directories + if (entry.name !== 'node_modules' && entry.name !== '.git') { + searchInDirectory(fullPath, baseDir); + } + } else if (entry.isFile()) { + // Check if the file matches the pattern + if (!filePattern || this.matchesPattern(entry.name, filePattern)) { + searchInFile(fullPath, relativePath); + } + } + } + } catch (error) { + console.error(`Error searching in directory ${dirPath}:`, error); + } + }; + + // Start the search from the project repository root + searchInDirectory(this.projectRepoPath, this.projectRepoPath); + + console.log(`Found ${results.length} matches for "${searchString}"`); + return results; + } + + /** + * Check if a filename matches a simple pattern + * @param filename Filename to check + * @param pattern Pattern to match (supports * wildcard) + * @returns True if the filename matches the pattern + */ + private matchesPattern(filename: string, pattern: string): boolean { + // Convert the pattern to a regex + // Escape special regex characters except * + const regexPattern = pattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex chars + .replace(/\*/g, '.*'); // Convert * to .* + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(filename); + } } diff --git a/src/functions/prompts-to-test-spec/src/services/gemini-service.ts b/src/functions/prompts-to-test-spec/src/services/gemini-service.ts index 44b2e13..8db23f9 100644 --- a/src/functions/prompts-to-test-spec/src/services/gemini-service.ts +++ b/src/functions/prompts-to-test-spec/src/services/gemini-service.ts @@ -2,22 +2,43 @@ * Service for handling Gemini API operations */ import { - VertexAI, - FunctionDeclaration, - Tool, FunctionDeclarationSchemaType, - GenerateContentRequest + GenerateContentCandidate, + GenerateContentRequest, + Tool, + VertexAI } from '@google-cloud/vertexai'; -import * as fs from 'fs'; -import * as path from 'path'; -import {Project, Workitem} from '../types'; -import { - GOOGLE_CLOUD_PROJECT_ID, - GOOGLE_CLOUD_LOCATION, - GEMINI_MODEL, - DRY_RUN_SKIP_GEMINI, - GOOGLE_API_KEY -} from '../config'; +import {Workitem} from '../types'; +import {DRY_RUN_SKIP_GEMINI, GEMINI_MODEL, GOOGLE_CLOUD_LOCATION, GOOGLE_CLOUD_PROJECT_ID} from '../config'; +import {GeminiProjectProcessor} from './gemini-project-processor'; + + +/** + * Interface for the model response format + */ +interface ModelResponse { + decision: 'create' | 'update' | 'delete' | 'skip'; + reason: string; +} + +/** + * Interface for the result returned by generateFeatureFile + */ +interface GenerateFeatureFileResult { + text: string; + decision?: ModelResponse; +} + +/** + * Interface for function arguments + */ +interface FunctionArgs { + filePath?: string; + content?: string; + dirPath?: string; + searchString?: string; + filePattern?: string; +} export class GeminiService { private vertexAI: VertexAI; @@ -26,6 +47,12 @@ export class GeminiService { private location: string; private fileOperationTools: Tool[]; + /** + * Create a new GeminiService instance + * @param projectId Google Cloud project ID (defaults to GOOGLE_CLOUD_PROJECT_ID from config) + * @param location Google Cloud location (defaults to GOOGLE_CLOUD_LOCATION from config) + * @param model Gemini model to use (defaults to GEMINI_MODEL from config) + */ constructor(projectId?: string, location?: string, model?: string) { this.projectId = projectId || GOOGLE_CLOUD_PROJECT_ID; this.location = location || GOOGLE_CLOUD_LOCATION; @@ -147,623 +174,14 @@ export class GeminiService { ]; } - /** - * Generate feature file content using Gemini API with streaming - * @param guidelines Project guidelines - * @param workitemContent Workitem content - * @param workitemName Name of the workitem - * @param geminiProjectProcessor Optional GeminiProjectProcessor to handle function calls - * @param additionalContext Optional additional context from relevant files - * @returns Generated feature file content - */ - async generateFeatureFile( - guidelines: string, - workitemContent: string, - workitemName: string, - geminiProjectProcessor?: any, - additionalContext: string = '' - ): Promise { - const currentDate = new Date().toISOString(); - - // If dry run is enabled, return a mock feature file - if (DRY_RUN_SKIP_GEMINI) { - console.log(`[DRY RUN] Skipping Gemini API call for generating feature file for ${workitemName}`); - return `# Generated by prompts-to-test-spec on ${currentDate} (DRY RUN) -# Source: ${workitemName} - -Feature: ${workitemName} (DRY RUN) - This is a mock feature file generated during dry run. - No actual Gemini API call was made. - - Scenario: Mock scenario - Given a dry run is enabled - When the feature file is generated - Then a mock feature file is returned -`; - } - - // Create the prompt - const prompt = ` -${guidelines} - -Workitem: -${workitemContent} - -You are tasked with implementing the workitem in the project repository according to the guidelines provided. -You have full control over how to implement the workitem, and you can decide what actions to take. - -Include the following comment at the top of any generated files: -# Generated by prompts-to-test-spec on ${currentDate} -# Source: ${workitemName} - -You have access to the following function calls to help you understand the project structure and create implementations: -- getFileContent(filePath): Get the content of a file in the project repository -- writeFileContent(filePath, content): Write content to a file in the project repository -- fileExists(filePath): Check if a file exists in the project repository -- listFiles(dirPath): List files in a directory in the project repository -- grepFiles(searchString, filePattern): Search for a string in project files, optionally filtered by a file pattern -- deleteFile(filePath): Delete a file from the project repository - -You can decide whether to create, update, delete or skip implementing this workitem based on your analysis. - -In your response, just include your decision with a short motivation in json format. For instance: -{ "decision": "create", "reason": "This workitem was not implemented" } - -${additionalContext ? `\nAdditional context from project files:${additionalContext}` : ''} -`; - - // Initialize Vertex with your Cloud project and location - const vertexAI = new VertexAI({ - project: this.projectId, - location: this.location, - }); - - // Instantiate the model with our file operation tools - const generativeModel = vertexAI.getGenerativeModel({ - model: this.model, - tools: geminiProjectProcessor ? this.fileOperationTools : undefined, - generation_config: { - temperature: 0.1, // Very low temperature for more deterministic responses - top_p: 0.95, // Higher top_p to allow more diverse completions when needed - top_k: 40, // Consider only the top 40 tokens - }, - }); - - // Create the initial request - const request: GenerateContentRequest = { - contents: [ - {role: 'user', parts: [{text: prompt}]} - ], - tools: geminiProjectProcessor ? this.fileOperationTools : undefined, - }; - - // Generate content in a streaming fashion - const streamingResp = await generativeModel.generateContentStream(request); - - let finalResponse = ''; - let pendingFunctionCalls = []; - - // Process the streaming response - for await (const item of streamingResp.stream) { - // Check if there's a function call in any part of the response - let functionCall = null; - let textContent = ''; - - // Iterate over every part in the response - for (const part of item.candidates?.[0]?.content?.parts || []) { - if (part.functionCall) { - functionCall = part.functionCall; - break; - } else if (part.text) { - textContent += part.text; - } - } - - if (functionCall) { - console.log(`Function call detected: ${functionCall.name}`); - pendingFunctionCalls.push(functionCall); - } else if (textContent) { - // If there's text, append it to the final response - finalResponse += textContent; - } - } - - // Process any function calls that were detected - if (pendingFunctionCalls.length > 0 && geminiProjectProcessor) { - console.log(`Processing ${pendingFunctionCalls.length} function calls from streaming response`); - - let currentRequest: GenerateContentRequest = request; - - // Process each function call - for (const functionCall of pendingFunctionCalls) { - const functionName = functionCall.name; - const functionArgs = (typeof functionCall.args === 'string' ? - JSON.parse(functionCall.args) : functionCall.args) as { - filePath?: string; - content?: string; - dirPath?: string; - searchString?: string; - filePattern?: string; - }; - - console.log(`Executing function: ${functionName} with args:`, functionArgs); - - let functionResponse; - try { - // Execute the function using the GeminiProjectProcessor - switch (functionName) { - case 'getFileContent': - functionResponse = geminiProjectProcessor.getFileContent(functionArgs.filePath!); - break; - case 'writeFileContent': - // Get the current workitem name from the context - const currentWorkitem = geminiProjectProcessor.getCurrentWorkitem(); - geminiProjectProcessor.writeFileContent(functionArgs.filePath!, functionArgs.content!, currentWorkitem?.name); - functionResponse = `File ${functionArgs.filePath} written successfully`; - break; - case 'fileExists': - functionResponse = geminiProjectProcessor.fileExists(functionArgs.filePath!); - break; - case 'listFiles': - functionResponse = geminiProjectProcessor.listFiles(functionArgs.dirPath!); - break; - case 'grepFiles': - functionResponse = geminiProjectProcessor.grepFiles(functionArgs.searchString!, functionArgs.filePattern); - break; - case 'deleteFile': - functionResponse = geminiProjectProcessor.deleteFile(functionArgs.filePath!); - break; - default: - throw new Error(`Unknown function: ${functionName}`); - } - - // Create a function response object - const functionResponseObj = { - name: functionName, - response: {result: JSON.stringify(functionResponse)} - }; - - // Update the request with the function call and response - currentRequest = { - contents: [ - ...currentRequest.contents, - { - role: 'ASSISTANT', - parts: [ - { - functionCall: functionCall - } - ] - }, - { - role: 'USER', - parts: [ - { - functionResponse: functionResponseObj - } - ] - } - ], - tools: geminiProjectProcessor ? this.fileOperationTools : undefined, - }; - - // Generate the next response - const nextStreamingResp = await generativeModel.generateContentStream(currentRequest); - - // Process the next streaming response - for await (const nextItem of nextStreamingResp.stream) { - let textContent = ''; - - // Iterate over every part in the response - for (const part of nextItem.candidates?.[0]?.content?.parts || []) { - if (part.text) { - textContent += part.text; - } - } - - if (textContent) { - finalResponse += textContent; - } - } - - } catch (error) { - console.error(`Error executing function ${functionName}:`, error); - - // Create an error response object - const errorResponseObj = { - name: functionName, - response: {error: error instanceof Error ? error.message : String(error)} - }; - - // Update the request with the function call and error response - currentRequest = { - contents: [ - ...currentRequest.contents, - { - role: 'ASSISTANT', - parts: [ - { - functionCall: functionCall - } - ] - }, - { - role: 'USER', - parts: [ - { - functionResponse: errorResponseObj - } - ] - } - ], - tools: geminiProjectProcessor ? this.fileOperationTools : undefined, - }; - - // Generate the next response - const nextStreamingResp = await generativeModel.generateContentStream(currentRequest); - - // Process the next streaming response - for await (const nextItem of nextStreamingResp.stream) { - let textContent = ''; - - // Iterate over every part in the response - for (const part of nextItem.candidates?.[0]?.content?.parts || []) { - if (part.text) { - textContent += part.text; - } - } - - if (textContent) { - finalResponse += textContent; - } - } - } - } - } - - console.log(`Gemini response for ${workitemName}: ${finalResponse}`); - return finalResponse; - } - - /** - * Process function calls in the Gemini response - * @param result The result from Gemini - * @param chat The chat session - * @param geminiProjectProcessor The GeminiProjectProcessor to handle function calls - * @returns The final generated text - */ - private async processFunctionCalls(result: any, chat: any, geminiProjectProcessor?: any): Promise { - // Check if there are function calls in the response - // Function calls can be at the top level or nested within candidates - const functionCalls = result.functionCalls || - (result.response?.candidates?.[0]?.functionCall ? [result.response.candidates[0].functionCall] : []) || - (result.candidates?.[0]?.functionCall ? [result.candidates[0].functionCall] : []); - - if (functionCalls.length === 0 || !geminiProjectProcessor) { - // No function calls, return the text response - // Access text content from the response structure in @google-cloud/vertexai v0.5.0 - return result.candidates?.[0]?.content?.parts?.[0]?.text || - result.response?.candidates?.[0]?.content?.parts?.[0]?.text || ''; - } - - console.log(`Processing ${functionCalls.length} function calls from Gemini`); - - // Process each function call - for (const functionCall of functionCalls) { - const functionName = functionCall.name; - // Handle both cases: when args is already an object and when it's a string that needs to be parsed - const functionArgs = (typeof functionCall.args === 'string' ? - JSON.parse(functionCall.args) : functionCall.args) as { - filePath?: string; - content?: string; - dirPath?: string; - searchString?: string; - filePattern?: string; - }; - - console.log(`Executing function: ${functionName} with args:`, functionArgs); - - let functionResponse; - try { - // Execute the function using the GeminiProjectProcessor - switch (functionName) { - case 'getFileContent': - functionResponse = geminiProjectProcessor.getFileContent(functionArgs.filePath!); - break; - case 'writeFileContent': - // Get the current workitem name from the context - const currentWorkitem = geminiProjectProcessor.getCurrentWorkitem(); - geminiProjectProcessor.writeFileContent(functionArgs.filePath!, functionArgs.content!, currentWorkitem?.name); - functionResponse = `File ${functionArgs.filePath} written successfully`; - break; - case 'fileExists': - functionResponse = geminiProjectProcessor.fileExists(functionArgs.filePath!); - break; - case 'listFiles': - functionResponse = geminiProjectProcessor.listFiles(functionArgs.dirPath!); - break; - case 'grepFiles': - functionResponse = geminiProjectProcessor.grepFiles(functionArgs.searchString!, functionArgs.filePattern); - break; - case 'deleteFile': - functionResponse = geminiProjectProcessor.deleteFile(functionArgs.filePath!); - break; - default: - throw new Error(`Unknown function: ${functionName}`); - } - - // Send the function response back to Gemini - const functionResponseObj = { - functionResponse: { - name: functionName, - response: {result: JSON.stringify(functionResponse)} - } - }; - const nextResult = await chat.sendMessage(functionResponseObj); - - // Recursively process any additional function calls - return this.processFunctionCalls(nextResult, chat, geminiProjectProcessor); - } catch (error) { - console.error(`Error executing function ${functionName}:`, error); - - // Send the error back to Gemini - const errorResponseObj = { - functionResponse: { - name: functionName, - response: {error: error instanceof Error ? error.message : String(error)} - } - }; - const nextResult = await chat.sendMessage(errorResponseObj); - - // Recursively process any additional function calls - return this.processFunctionCalls(nextResult, chat, geminiProjectProcessor); - } - } - - // Return the final text response - // Access text content from the response structure in @google-cloud/vertexai v0.5.0 - return result.candidates?.[0]?.content?.parts?.[0]?.text || ''; - } - - /** - * Example of using function calling with streaming content generation - * This method demonstrates how to use the Vertex AI API for function calling in a streaming context - * @param projectId Google Cloud project ID - * @param location Google Cloud location - * @param model Gemini model to use - */ - async functionCallingStreamExample( - projectId: string = this.projectId, - location: string = this.location, - model: string = this.model - ): Promise { - // Initialize Vertex with your Cloud project and location - const vertexAI = new VertexAI({project: projectId, location: location}); - - // Instantiate the model - const generativeModel = vertexAI.getGenerativeModel({ - model: model, - }); - - // Define the function declaration for the weather function - const functionDeclarations: FunctionDeclaration[] = [ - { - name: "get_current_weather", - description: "Get the current weather in a given location", - parameters: { - type: FunctionDeclarationSchemaType.OBJECT, - properties: { - location: { - type: FunctionDeclarationSchemaType.STRING, - description: "The city and state, e.g., San Francisco, CA" - } - }, - required: ["location"] - } - } - ]; - - // Create a mock function response - const functionResponseParts = [ - { - functionResponse: { - name: "get_current_weather", - response: { - temperature: "72", - unit: "fahrenheit", - description: "Sunny" - } - } - } - ]; - - // Create the request with function calling - const request = { - contents: [ - {role: 'user', parts: [{text: 'What is the weather in Boston?'}]}, - { - role: 'ASSISTANT', - parts: [ - { - functionCall: { - name: 'get_current_weather', - args: {location: 'Boston'}, - }, - }, - ], - }, - {role: 'USER', parts: functionResponseParts}, - ], - tools: [{function_declarations: functionDeclarations}], - }; - - // Generate content in a streaming fashion - const streamingResp = await generativeModel.generateContentStream(request); - - // Process the streaming response - for await (const item of streamingResp.stream) { - // Iterate over every part in the response - for (const part of item.candidates?.[0]?.content?.parts || []) { - if (part.text) { - console.log(part.text); - } - } - } - } - - /** - * Example of using function calling with streaming content generation for file operations - * This method demonstrates how to use the Vertex AI API for file operation function calling in a streaming context - * @param workitemName Name of the workitem - * @param geminiProjectProcessor The GeminiProjectProcessor to handle function calls - */ - async fileOperationsStreamExample( - workitemName: string, - geminiProjectProcessor: any - ): Promise { - // Initialize Vertex with your Cloud project and location - const vertexAI = new VertexAI({ - project: this.projectId, - location: this.location, - }); - - // Instantiate the model with our file operation tools - const generativeModel = vertexAI.getGenerativeModel({ - model: this.model, - tools: this.fileOperationTools, - }); - - // Create a prompt that asks the model to check if a file exists and create it if it doesn't - const prompt = `Check if the file 'example.txt' exists and create it with some content if it doesn't.`; - - // Create the initial request - const request = { - contents: [ - {role: 'user', parts: [{text: prompt}]} - ], - tools: this.fileOperationTools, - }; - - // Generate content in a streaming fashion - const streamingResp = await generativeModel.generateContentStream(request); - - // Process the streaming response - for await (const item of streamingResp.stream) { - // Check if there's a function call in any part of the response - let functionCall = null; - - // Iterate over every part in the response - for (const part of item.candidates?.[0]?.content?.parts || []) { - if (part.functionCall) { - functionCall = part.functionCall; - break; - } - } - - if (functionCall) { - console.log(`Function call detected: ${functionCall.name}`); - - // Execute the function - const functionName = functionCall.name; - const functionArgs = functionCall.args as { - filePath?: string; - content?: string; - dirPath?: string; - searchString?: string; - filePattern?: string; - }; - - console.log(`Executing function: ${functionName} with args:`, functionArgs); - - let functionResponse; - try { - // Execute the function using the GeminiProjectProcessor - switch (functionName) { - case 'getFileContent': - functionResponse = geminiProjectProcessor.getFileContent(functionArgs.filePath!); - break; - case 'writeFileContent': - geminiProjectProcessor.writeFileContent(functionArgs.filePath!, functionArgs.content!, workitemName); - functionResponse = `File ${functionArgs.filePath} written successfully`; - break; - case 'fileExists': - functionResponse = geminiProjectProcessor.fileExists(functionArgs.filePath!); - break; - case 'listFiles': - functionResponse = geminiProjectProcessor.listFiles(functionArgs.dirPath!); - break; - case 'grepFiles': - functionResponse = geminiProjectProcessor.grepFiles(functionArgs.searchString!, functionArgs.filePattern); - break; - case 'deleteFile': - functionResponse = geminiProjectProcessor.deleteFile(functionArgs.filePath!); - break; - default: - throw new Error(`Unknown function: ${functionName}`); - } - - // Create a new request with the function response - const functionResponseObj = { - name: functionName, - response: {result: JSON.stringify(functionResponse)} - }; - - // Continue the conversation with the function response - const nextRequest = { - contents: [ - {role: 'user', parts: [{text: prompt}]}, - { - role: 'ASSISTANT', - parts: [ - { - functionCall: functionCall - } - ] - }, - { - role: 'USER', - parts: [ - { - functionResponse: functionResponseObj - } - ] - } - ], - tools: this.fileOperationTools, - }; - - // Generate the next response - const nextStreamingResp = await generativeModel.generateContentStream(nextRequest); - - // Process the next streaming response - for await (const nextItem of nextStreamingResp.stream) { - // Iterate over every part in the response - for (const part of nextItem.candidates?.[0]?.content?.parts || []) { - if (part.text) { - console.log(part.text); - } - } - } - - } catch (error) { - console.error(`Error executing function ${functionName}:`, error); - } - } else { - // If there's no function call, just log the text from all parts - for (const part of item.candidates?.[0]?.content?.parts || []) { - if (part.text) { - console.log(part.text); - } - } - } - } - } - /** * Generate a pull request description using Gemini API - * @param processedWorkitems List of processed workitems + * @param processedWorkitems List of processed workitems with their status * @param gitPatch Optional git patch showing code changes - * @returns Generated pull request description + * @returns Generated pull request description in markdown format + * @example + * const geminiService = new GeminiService(); + * const prDescription = await geminiService.generatePullRequestDescription(processedWorkitems, gitPatch); */ async generatePullRequestDescription( processedWorkitems: { workitem: Workitem; success: boolean; error?: string }[], diff --git a/src/functions/prompts-to-test-spec/src/services/model-stream-service.ts b/src/functions/prompts-to-test-spec/src/services/model-stream-service.ts new file mode 100644 index 0000000..10eccf1 --- /dev/null +++ b/src/functions/prompts-to-test-spec/src/services/model-stream-service.ts @@ -0,0 +1,578 @@ +/** + * Service for handling model streams for specific workitems + */ +import { + FunctionDeclarationSchemaType, + GenerateContentCandidate, + GenerateContentRequest, + Tool, + VertexAI +} from '@google-cloud/vertexai'; +import { Workitem } from '../types'; +import { DRY_RUN_SKIP_GEMINI, GEMINI_MODEL, GOOGLE_CLOUD_LOCATION, GOOGLE_CLOUD_PROJECT_ID } from '../config'; +import { GeminiProjectProcessor } from './gemini-project-processor'; + +/** + * Interface for the model response format + */ +interface ModelResponse { + decision: 'create' | 'update' | 'delete' | 'skip'; + reason: string; +} + +/** + * Interface for function arguments + */ +interface FunctionArgs { + filePath?: string; + content?: string; + dirPath?: string; + searchString?: string; + filePattern?: string; + decision?: 'create' | 'update' | 'delete' | 'skip'; + reason?: string; +} + +/** + * Interface for streaming response item + */ +interface StreamResponseItem { + candidates?: GenerateContentCandidate[]; + usageMetadata?: any; +} + +/** + * Interface for the result of processing a model stream + */ +export interface ModelStreamResult { + text: string; + decision?: ModelResponse; + filesWritten: string[]; + filesDeleted: string[]; +} + +/** + * Service for handling model streams for specific workitems + */ +export class ModelStreamService { + private vertexAI: VertexAI; + private model: string; + private projectId: string; + private location: string; + private fileOperationTools: Tool[]; + private geminiProjectProcessor: GeminiProjectProcessor; + private workitem: Workitem; + + // State tracking + private filesWritten: string[] = []; + private filesDeleted: string[] = []; + private modelResponses: string[] = []; + private decision?: ModelResponse; + + /** + * Create a new ModelStreamService instance + * @param geminiProjectProcessor GeminiProjectProcessor to handle function calls + * @param workitem Workitem being processed + * @param projectId Google Cloud project ID (defaults to GOOGLE_CLOUD_PROJECT_ID from config) + * @param location Google Cloud location (defaults to GOOGLE_CLOUD_LOCATION from config) + * @param model Gemini model to use (defaults to GEMINI_MODEL from config) + */ + constructor( + geminiProjectProcessor: GeminiProjectProcessor, + workitem: Workitem, + projectId?: string, + location?: string, + model?: string, + private dryRun?: boolean + ) { + this.geminiProjectProcessor = geminiProjectProcessor; + this.workitem = workitem; + this.projectId = projectId || GOOGLE_CLOUD_PROJECT_ID; + this.location = location || GOOGLE_CLOUD_LOCATION; + this.model = model || GEMINI_MODEL; + + if (!this.projectId) { + throw new Error('Google Cloud Project ID is required'); + } + + // Initialize VertexAI with default authentication + this.vertexAI = new VertexAI({ + project: this.projectId, + location: this.location, + }); + + // Define file operation functions and decision function + this.fileOperationTools = [ + { + function_declarations: [ + { + name: "getFileContent", + description: "Get the content of a file in the project repository", + parameters: { + type: FunctionDeclarationSchemaType.OBJECT, + properties: { + filePath: { + type: FunctionDeclarationSchemaType.STRING, + description: "Path to the file relative to the project repository root" + } + }, + required: ["filePath"] + } + }, + { + name: "writeFileContent", + description: "Write content to a file in the project repository", + parameters: { + type: FunctionDeclarationSchemaType.OBJECT, + properties: { + filePath: { + type: FunctionDeclarationSchemaType.STRING, + description: "Path to the file relative to the project repository root" + }, + content: { + type: FunctionDeclarationSchemaType.STRING, + description: "Content to write to the file" + } + }, + required: ["filePath", "content"] + } + }, + { + name: "fileExists", + description: "Check if a file exists in the project repository", + parameters: { + type: FunctionDeclarationSchemaType.OBJECT, + properties: { + filePath: { + type: FunctionDeclarationSchemaType.STRING, + description: "Path to the file relative to the project repository root" + } + }, + required: ["filePath"] + } + }, + { + name: "listFiles", + description: "List files in a directory in the project repository", + parameters: { + type: FunctionDeclarationSchemaType.OBJECT, + properties: { + dirPath: { + type: FunctionDeclarationSchemaType.STRING, + description: "Path to the directory relative to the project repository root" + } + }, + required: ["dirPath"] + } + }, + { + name: "grepFiles", + description: "Search for a string in project files", + parameters: { + type: FunctionDeclarationSchemaType.OBJECT, + properties: { + searchString: { + type: FunctionDeclarationSchemaType.STRING, + description: "String to search for in project files" + }, + filePattern: { + type: FunctionDeclarationSchemaType.STRING, + description: "Optional file pattern to limit the search (e.g., '*.ts', 'src/*.java')" + } + }, + required: ["searchString"] + } + }, + { + name: "deleteFile", + description: "Delete a file from the project repository", + parameters: { + type: FunctionDeclarationSchemaType.OBJECT, + properties: { + filePath: { + type: FunctionDeclarationSchemaType.STRING, + description: "Path to the file relative to the project repository root" + } + }, + required: ["filePath"] + } + }, + { + name: "makeDecision", + description: "State your decision about implementing the workitem", + parameters: { + type: FunctionDeclarationSchemaType.OBJECT, + properties: { + decision: { + type: FunctionDeclarationSchemaType.STRING, + description: "Your decision: 'create', 'update', 'delete', or 'skip'", + enum: ["create", "update", "delete", "skip"] + }, + reason: { + type: FunctionDeclarationSchemaType.STRING, + description: "Reason for your decision" + } + }, + required: ["decision", "reason"] + } + } + ] + } + ]; + } + + /** + * Process a model stream for a workitem + * @param guidelines Project guidelines + * @param workitemContent Workitem content + * @param additionalContext Optional additional context from relevant files + * @returns Object containing the generated text, parsed decision, and files written/deleted + */ + async processModelStream( + guidelines: string, + workitemContent: string, + additionalContext: string = '' + ): Promise { + const currentDate = new Date().toISOString(); + + // If dry run is enabled, return a mock result + if (this.dryRun || DRY_RUN_SKIP_GEMINI) { + console.log(`[DRY RUN] Skipping Gemini API call for processing workitem ${this.workitem.name}`); + const mockText = `# Generated by prompts-to-test-spec on ${currentDate} (DRY RUN) +# Source: ${this.workitem.name} + +Feature: ${this.workitem.name} (DRY RUN) + This is a mock feature file generated during dry run. + No actual Gemini API call was made. + + Scenario: Mock scenario + Given a dry run is enabled + When the feature file is generated + Then a mock feature file is returned +`; + return { + text: mockText, + decision: { + decision: 'create', + reason: 'This is a mock decision for dry run mode' + }, + filesWritten: [], + filesDeleted: [] + }; + } + + // Create the prompt + const prompt = ` +${guidelines} + +Workitem: +${workitemContent} + +You are tasked with implementing the workitem in the project repository according to the guidelines provided. +You have full control over how to implement the workitem, and you can decide what actions to take. + +Include the following comment at the top of any generated files: +# Generated by prompts-to-test-spec on ${currentDate} +# Source: ${this.workitem.name} + +You have access to the following function calls to help you understand the project structure and create implementations: +- getFileContent(filePath): Get the content of a file in the project repository +- writeFileContent(filePath, content): Write content to a file in the project repository +- fileExists(filePath): Check if a file exists in the project repository +- listFiles(dirPath): List files in a directory in the project repository +- grepFiles(searchString, filePattern): Search for a string in project files, optionally filtered by a file pattern +- deleteFile(filePath): Delete a file from the project repository +- makeDecision(decision, reason): State your decision about implementing the workitem. Decision must be one of: 'create', 'update', 'delete', 'skip' + +You can decide whether to create, update, delete or skip implementing this workitem based on your analysis. + +IMPORTANT!!: First use the function calls above to actually implement the workitem. Make all necessary function calls to fully implement the workitem. + +After you have implemented the workitem using function calls, use the makeDecision function to state your final decision with a reason. + +${additionalContext ? `\nAdditional context from project files:${additionalContext}` : ''} +`; + + // Instantiate the model with our file operation tools + const generativeModel = this.vertexAI.getGenerativeModel({ + model: this.model, + tools: this.fileOperationTools, + generation_config: { + temperature: 0.1, // Very low temperature for more deterministic responses + top_p: 0.95, // Higher top_p to allow more diverse completions when needed + top_k: 40, // Consider only the top 40 tokens + }, + }); + + // Create the initial request + const request: GenerateContentRequest = { + contents: [ + {role: 'user', parts: [{text: prompt}]} + ], + tools: this.fileOperationTools, + }; + + // Generate content in a streaming fashion + const streamingResp = await generativeModel.generateContentStream(request); + + let finalResponse = ''; + let pendingFunctionCalls = []; + + // Process the streaming response + for await (const item of streamingResp.stream) { + // Add debug logging for each item in the model stream + console.log(`[DEBUG] Processing stream item`); + // Avoid stringifying the entire item which can be too complex + if (item.candidates && item.candidates.length > 0) { + console.log(`[DEBUG] Item has ${item.candidates.length} candidates`); + } + + // Check if there's a function call in any part of the response + let functionCall = null; + let textContent = ''; + + // Iterate over every part in the response + for (const part of item.candidates?.[0]?.content?.parts || []) { + if (part.functionCall) { + functionCall = part.functionCall; + console.log(`[DEBUG] Function call detected in stream: ${functionCall.name}`); + break; + } else if (part.text) { + textContent += part.text; + console.log(`[DEBUG] Text content detected in stream: ${textContent.substring(0, 100)}${textContent.length > 100 ? '...' : ''}`); + } + } + + if (functionCall) { + console.log(`Function call detected: ${functionCall.name}`); + pendingFunctionCalls.push(functionCall); + } else if (textContent) { + // If there's text, append it to the final response + finalResponse += textContent; + this.modelResponses.push(textContent); + } + } + + // Process any function calls that were detected + if (pendingFunctionCalls.length > 0) { + console.log(`Processing ${pendingFunctionCalls.length} function calls from streaming response`); + + let currentRequest: GenerateContentRequest = request; + + // Process each function call + for (const functionCall of pendingFunctionCalls) { + const functionName = functionCall.name; + const functionArgs = (typeof functionCall.args === 'string' ? + JSON.parse(functionCall.args) : functionCall.args) as FunctionArgs; + + console.log(`Executing function: ${functionName} with args:`, functionArgs); + + let functionResponse; + try { + // Execute the function + switch (functionName) { + case 'getFileContent': + functionResponse = this.geminiProjectProcessor.getFileContent(functionArgs.filePath!); + break; + case 'writeFileContent': + this.geminiProjectProcessor.writeFileContent(functionArgs.filePath!, functionArgs.content!, this.workitem.name); + functionResponse = `File ${functionArgs.filePath} written successfully`; + // Track the file written + this.filesWritten.push(functionArgs.filePath!); + break; + case 'fileExists': + functionResponse = this.geminiProjectProcessor.fileExists(functionArgs.filePath!); + break; + case 'listFiles': + functionResponse = this.geminiProjectProcessor.listFiles(functionArgs.dirPath!); + break; + case 'grepFiles': + functionResponse = this.geminiProjectProcessor.grepFiles(functionArgs.searchString!, functionArgs.filePattern); + break; + case 'deleteFile': + functionResponse = this.geminiProjectProcessor.deleteFile(functionArgs.filePath!); + // Track the file deleted + this.filesDeleted.push(functionArgs.filePath!); + break; + case 'makeDecision': + // Store the decision + this.decision = { + decision: functionArgs.decision!, + reason: functionArgs.reason! + }; + functionResponse = `Decision recorded: ${functionArgs.decision} - ${functionArgs.reason}`; + console.log(`Model decision: ${functionArgs.decision} - ${functionArgs.reason}`); + break; + default: + throw new Error(`Unknown function: ${functionName}`); + } + + // Create a function response object + const functionResponseObj = { + name: functionName, + response: {result: JSON.stringify(functionResponse)} + }; + + // Update the request with the function call and response + currentRequest = { + contents: [ + ...currentRequest.contents, + { + role: 'ASSISTANT', + parts: [ + { + functionCall: functionCall + } + ] + }, + { + role: 'USER', + parts: [ + { + functionResponse: functionResponseObj + } + ] + } + ], + tools: this.fileOperationTools, + }; + + // Generate the next response + const nextStreamingResp = await generativeModel.generateContentStream(currentRequest); + + // Process the next streaming response + for await (const nextItem of nextStreamingResp.stream) { + console.log(`[DEBUG] Processing next stream item`); + // Avoid stringifying the entire item which can be too complex + if (nextItem.candidates && nextItem.candidates.length > 0) { + console.log(`[DEBUG] Next item has ${nextItem.candidates.length} candidates`); + } + + let textContent = ''; + let nextFunctionCall = null; + + // Iterate over every part in the response + for (const part of nextItem.candidates?.[0]?.content?.parts || []) { + if (part.functionCall) { + nextFunctionCall = part.functionCall; + console.log(`[DEBUG] Function call detected in next stream: ${nextFunctionCall.name}`); + break; + } else if (part.text) { + textContent += part.text; + console.log(`[DEBUG] Text content detected in next stream: ${textContent.substring(0, 100)}${textContent.length > 100 ? '...' : ''}`); + } + } + + if (nextFunctionCall) { + // Add to pending function calls to be processed + pendingFunctionCalls.push(nextFunctionCall); + } else if (textContent) { + finalResponse += textContent; + this.modelResponses.push(textContent); + } + } + + } catch (error) { + console.error(`Error executing function ${functionName}:`, error); + + // Create an error response object + const errorResponseObj = { + name: functionName, + response: {error: error instanceof Error ? error.message : String(error)} + }; + + // Update the request with the function call and error response + currentRequest = { + contents: [ + ...currentRequest.contents, + { + role: 'ASSISTANT', + parts: [ + { + functionCall: functionCall + } + ] + }, + { + role: 'USER', + parts: [ + { + functionResponse: errorResponseObj + } + ] + } + ], + tools: this.fileOperationTools, + }; + + // Generate the next response + const nextStreamingResp = await generativeModel.generateContentStream(currentRequest); + + // Process the next streaming response + for await (const nextItem of nextStreamingResp.stream) { + console.log(`[DEBUG] Processing next stream item after error`); + // Avoid stringifying the entire item which can be too complex + if (nextItem.candidates && nextItem.candidates.length > 0) { + console.log(`[DEBUG] Next item after error has ${nextItem.candidates.length} candidates`); + } + + let textContent = ''; + let nextFunctionCall = null; + + // Iterate over every part in the response + for (const part of nextItem.candidates?.[0]?.content?.parts || []) { + if (part.functionCall) { + nextFunctionCall = part.functionCall; + console.log(`[DEBUG] Function call detected in next stream after error: ${nextFunctionCall.name}`); + break; + } else if (part.text) { + textContent += part.text; + console.log(`[DEBUG] Text content detected in next stream after error: ${textContent.substring(0, 100)}${textContent.length > 100 ? '...' : ''}`); + } + } + + if (nextFunctionCall) { + // Add to pending function calls to be processed + pendingFunctionCalls.push(nextFunctionCall); + } else if (textContent) { + finalResponse += textContent; + this.modelResponses.push(textContent); + } + } + } + } + } + + console.log(`Model stream processing completed for ${this.workitem.name}`); + console.log(`Files written: ${this.filesWritten.length}, Files deleted: ${this.filesDeleted.length}`); + + // If no explicit decision was made using the makeDecision function, try to parse it from the text + if (!this.decision) { + try { + // Try to parse a JSON decision from the text + const jsonMatch = finalResponse.match(/\{[\s\S]*"decision"[\s\S]*\}/); + if (jsonMatch) { + this.decision = JSON.parse(jsonMatch[0]) as ModelResponse; + console.log(`Parsed decision from text: ${this.decision.decision}, reason: ${this.decision.reason}`); + } + } catch (error) { + console.error(`Error parsing decision from text: ${error}`); + } + } + + return { + text: finalResponse, + decision: this.decision, + filesWritten: this.filesWritten, + filesDeleted: this.filesDeleted + }; + } + + /** + * Get all model responses collected during processing + * @returns Array of model response texts + */ + getModelResponses(): string[] { + return this.modelResponses; + } +} diff --git a/src/functions/prompts-to-test-spec/src/services/processor-service.ts b/src/functions/prompts-to-test-spec/src/services/processor-service.ts index fefb3ca..3930132 100644 --- a/src/functions/prompts-to-test-spec/src/services/processor-service.ts +++ b/src/functions/prompts-to-test-spec/src/services/processor-service.ts @@ -2,307 +2,297 @@ * Service for orchestrating the entire process */ import * as path from 'path'; -import { Project, ProcessResult, RepoCredentials } from '../types'; -import { RepositoryService } from './repository-service'; -import { ProjectService } from './project-service'; -import { GeminiService } from './gemini-service'; -import { PullRequestService } from './pull-request-service'; -import { GeminiProjectProcessor } from './gemini-project-processor'; -import { - MAIN_REPO_URL, - validateConfig, - getMainRepoCredentials, - getGithubCredentials, - getGiteaCredentials, - GOOGLE_CLOUD_PROJECT_ID, - GOOGLE_CLOUD_LOCATION, - GEMINI_MODEL, - USE_LOCAL_REPO, - DRY_RUN_SKIP_COMMITS +import {ProcessResult, Project, RepoCredentials} from '../types'; +import {RepositoryService} from './repository-service'; +import {ProjectService} from './project-service'; +import {PullRequestService} from './pull-request-service'; +import {GeminiProjectProcessor} from './gemini-project-processor'; +import { + DRY_RUN_SKIP_COMMITS, + getGiteaCredentials, + getGithubCredentials, + getMainRepoCredentials, + MAIN_REPO_URL, + USE_LOCAL_REPO, + validateConfig } from '../config'; export class ProcessorService { - private repositoryService: RepositoryService; - private projectService: ProjectService; - private geminiService: GeminiService; - private pullRequestService: PullRequestService; - private mainRepoUrl: string; - private mainRepoCredentials: RepoCredentials; - private giteaCredentials?: RepoCredentials; - private githubCredentials?: RepoCredentials; + private repositoryService: RepositoryService; + private projectService: ProjectService; + private pullRequestService: PullRequestService; + private mainRepoUrl: string; + private mainRepoCredentials: RepoCredentials; + private giteaCredentials?: RepoCredentials; + private githubCredentials?: RepoCredentials; - constructor() { - // Validate configuration - validateConfig(); + constructor() { + // Validate configuration + validateConfig(); - // Initialize services - this.repositoryService = new RepositoryService(); - this.projectService = new ProjectService(); - this.geminiService = new GeminiService( - GOOGLE_CLOUD_PROJECT_ID, - GOOGLE_CLOUD_LOCATION, - GEMINI_MODEL - ); - this.pullRequestService = new PullRequestService(); + // Initialize services + this.repositoryService = new RepositoryService(); + this.projectService = new ProjectService(); + this.pullRequestService = new PullRequestService(); - // Get main repository URL and credentials only if not using local repo - if (!USE_LOCAL_REPO) { - this.mainRepoUrl = MAIN_REPO_URL; - this.mainRepoCredentials = getMainRepoCredentials(); - } else { - // Set dummy values when using local repo - this.mainRepoUrl = ''; - this.mainRepoCredentials = getMainRepoCredentials(); - } - - // Initialize other credentials - this.githubCredentials = getGithubCredentials(); - this.giteaCredentials = getGiteaCredentials(); - } - - /** - * Get credentials for a project based on its repository host - * @param project Project information - * @returns Credentials for the project repository - */ - private getCredentialsForProject(project: Project): RepoCredentials { - if (!project.repoHost) { - throw new Error(`Repository host not found for project ${project.name}`); - } - - if (project.repoHost.includes('github.com')) { - if (!this.githubCredentials) { - throw new Error('GitHub credentials not found'); - } - return this.githubCredentials; - } else if (project.repoHost.includes('gitea')) { - if (!this.giteaCredentials) { - throw new Error('Gitea credentials not found'); - } - return this.giteaCredentials; - } else { - throw new Error(`Unsupported repository host: ${project.repoHost}`); - } - } - - /** - * Process all projects in the main repository - * @returns Array of process results - */ - async processProjects(): Promise { - const results: ProcessResult[] = []; - let mainRepoPath: string; - - try { - // Use local repository or clone the main repository - if (USE_LOCAL_REPO) { - console.log('Using local repository path'); - // When running with functions-framework, we need to navigate up to the project root - // Check if we're in the prompts-to-test-spec directory and navigate up if needed - const currentDir = process.cwd(); - if (currentDir.endsWith('prompts-to-test-spec')) { - mainRepoPath = path.resolve(currentDir, '../../..'); + // Get main repository URL and credentials only if not using local repo + if (!USE_LOCAL_REPO) { + this.mainRepoUrl = MAIN_REPO_URL; + this.mainRepoCredentials = getMainRepoCredentials(); } else { - mainRepoPath = currentDir; + // Set dummy values when using local repo + this.mainRepoUrl = ''; + this.mainRepoCredentials = getMainRepoCredentials(); } - console.log(`Resolved local repository path: ${mainRepoPath}`); - } else { - console.log(`Cloning main repository: ${this.mainRepoUrl}`); - mainRepoPath = await this.repositoryService.cloneMainRepository( - this.mainRepoUrl, - this.mainRepoCredentials - ); - } - // Find all projects in the prompts directory - const promptsDir = path.join(mainRepoPath, 'src', 'prompts'); - console.log(`Finding projects in: ${promptsDir}`); - const projects = await this.projectService.findProjects(promptsDir); + // Initialize other credentials + this.githubCredentials = getGithubCredentials(); + this.giteaCredentials = getGiteaCredentials(); + } - console.log(`Found ${projects.length} projects`); + /** + * Get credentials for a project based on its repository host + * @param project Project information + * @returns Credentials for the project repository + */ + private getCredentialsForProject(project: Project): RepoCredentials { + if (!project.repoHost) { + throw new Error(`Repository host not found for project ${project.name}`); + } - // Log details of each project - if (projects.length > 0) { - console.log('Projects found:'); - projects.forEach((project, index) => { - console.log(` ${index + 1}. ${project.name} (${project.path})`); - }); - } else { - console.log('No projects found. Check if the prompts directory exists and contains project folders.'); - } + if (project.repoHost.includes('github.com')) { + if (!this.githubCredentials) { + throw new Error('GitHub credentials not found'); + } + return this.githubCredentials; + } else if (project.repoHost.includes('gitea')) { + if (!this.giteaCredentials) { + throw new Error('Gitea credentials not found'); + } + return this.giteaCredentials; + } else { + throw new Error(`Unsupported repository host: ${project.repoHost}`); + } + } + + /** + * Process all projects in the main repository + * @returns Array of process results + */ + async processProjects(): Promise { + const results: ProcessResult[] = []; + let mainRepoPath: string; - // Process each project - console.log('Starting to process projects...'); - for (const project of projects) { try { - console.log(`Starting processing of project: ${project.name}`); - const result = await this.processProject(project, mainRepoPath); - console.log(`Finished processing project: ${project.name}`); - results.push(result); + // Use local repository or clone the main repository + if (USE_LOCAL_REPO) { + console.log('Using local repository path'); + // When running with functions-framework, we need to navigate up to the project root + // Check if we're in the prompts-to-test-spec directory and navigate up if needed + const currentDir = process.cwd(); + if (currentDir.endsWith('prompts-to-test-spec')) { + mainRepoPath = path.resolve(currentDir, '../../..'); + } else { + mainRepoPath = currentDir; + } + console.log(`Resolved local repository path: ${mainRepoPath}`); + } else { + console.log(`Cloning main repository: ${this.mainRepoUrl}`); + mainRepoPath = await this.repositoryService.cloneMainRepository( + this.mainRepoUrl, + this.mainRepoCredentials + ); + } + + // Find all projects in the prompts directory + const promptsDir = path.join(mainRepoPath, 'src', 'prompts'); + console.log(`Finding projects in: ${promptsDir}`); + const projects = await this.projectService.findProjects(promptsDir); + + console.log(`Found ${projects.length} projects`); + + // Log details of each project + if (projects.length > 0) { + console.log('Projects found:'); + projects.forEach((project, index) => { + console.log(` ${index + 1}. ${project.name} (${project.path})`); + }); + } else { + console.log('No projects found. Check if the prompts directory exists and contains project folders.'); + } + + // Process each project + console.log('Starting to process projects...'); + for (const project of projects) { + try { + console.log(`Starting processing of project: ${project.name}`); + const result = await this.processProject(project, mainRepoPath); + console.log(`Finished processing project: ${project.name}`); + results.push(result); + } catch (error) { + console.error(`Error processing project ${project.name}:`, error); + results.push({ + project, + processedWorkitems: [], + error: error instanceof Error ? error.message : String(error) + }); + } + } + console.log(`Finished processing all ${projects.length} projects`); + + // Update workitem files with pull request URLs and commit changes + if (!DRY_RUN_SKIP_COMMITS) { + await this.updateWorkitemFilesWithPullRequestUrls(results, mainRepoPath); + } else { + console.log('[DRY RUN] Skipping workitem files update and commit'); + } + + return results; } catch (error) { - console.error(`Error processing project ${project.name}:`, error); - results.push({ - project, - processedWorkitems: [], - error: error instanceof Error ? error.message : String(error) - }); + console.error('Error processing projects:', error); + throw error; } - } - console.log(`Finished processing all ${projects.length} projects`); - - // Update workitem files with pull request URLs and commit changes - if (!DRY_RUN_SKIP_COMMITS) { - await this.updateWorkitemFilesWithPullRequestUrls(results, mainRepoPath); - } else { - console.log('[DRY RUN] Skipping workitem files update and commit'); - } - - return results; - } catch (error) { - console.error('Error processing projects:', error); - throw error; } - } - /** - * Update workitem files with pull request URLs and commit changes to the main repository - * @param results Process results containing pull request URLs - * @param mainRepoPath Path to the main repository - */ - private async updateWorkitemFilesWithPullRequestUrls(results: ProcessResult[], mainRepoPath: string): Promise { - console.log('Updating workitem files with pull request URLs...'); - let updatedAnyWorkitem = false; + /** + * Update workitem files with pull request URLs and commit changes to the main repository + * @param results Process results containing pull request URLs + * @param mainRepoPath Path to the main repository + */ + private async updateWorkitemFilesWithPullRequestUrls(results: ProcessResult[], mainRepoPath: string): Promise { + console.log('Updating workitem files with pull request URLs...'); + let updatedAnyWorkitem = false; - // Create a new branch for the changes - const branchName = `update-workitem-pr-urls-${new Date().toISOString().split('T')[0]}`; - await this.repositoryService.createBranch(mainRepoPath, branchName); + // Create a new branch for the changes + const branchName = `update-workitem-pr-urls-${new Date().toISOString().split('T')[0]}`; + await this.repositoryService.createBranch(mainRepoPath, branchName); - // Update each workitem file with its pull request URL - for (const result of results) { - if (!result.pullRequestUrl) { - console.log(`Skipping project ${result.project.name}: No pull request URL`); - continue; - } + // Update each workitem file with its pull request URL + for (const result of results) { + if (!result.pullRequestUrl) { + console.log(`Skipping project ${result.project.name}: No pull request URL`); + continue; + } - for (const processedWorkitem of result.processedWorkitems) { - if (processedWorkitem.success) { - try { - console.log(`Updating workitem ${processedWorkitem.workitem.name} with PR URL: ${result.pullRequestUrl}`); - await this.projectService.updateWorkitemWithPullRequestUrl( - processedWorkitem.workitem, - result.pullRequestUrl + for (const processedWorkitem of result.processedWorkitems) { + if (processedWorkitem.success) { + try { + console.log(`Updating workitem ${processedWorkitem.workitem.name} with PR URL: ${result.pullRequestUrl}`); + await this.projectService.updateWorkitemWithPullRequestUrl( + processedWorkitem.workitem, + result.pullRequestUrl + ); + updatedAnyWorkitem = true; + } catch (error) { + console.error(`Error updating workitem ${processedWorkitem.workitem.name}:`, error); + } + } + } + } + + // Commit and push changes if any workitems were updated + if (updatedAnyWorkitem) { + console.log('Committing changes to workitem files...'); + await this.repositoryService.commitChanges( + mainRepoPath, + `Update workitem files with pull request URLs: ${new Date().toISOString().split('T')[0]}` ); - updatedAnyWorkitem = true; - } catch (error) { - console.error(`Error updating workitem ${processedWorkitem.workitem.name}:`, error); - } + + console.log('Pushing changes to main repository...'); + await this.repositoryService.pushChanges(mainRepoPath, branchName, this.mainRepoCredentials); + console.log('Successfully updated workitem files with pull request URLs'); + } else { + console.log('No workitem files were updated'); } - } } - // Commit and push changes if any workitems were updated - if (updatedAnyWorkitem) { - console.log('Committing changes to workitem files...'); - await this.repositoryService.commitChanges( - mainRepoPath, - `Update workitem files with pull request URLs: ${new Date().toISOString().split('T')[0]}` - ); + /** + * Process a single project + * @param project Project information + * @param mainRepoPath Path to the main repository + * @returns Process result + */ + async processProject(project: Project, mainRepoPath: string): Promise { + console.log(`Processing project: ${project.name}`); - console.log('Pushing changes to main repository...'); - await this.repositoryService.pushChanges(mainRepoPath, branchName, this.mainRepoCredentials); - console.log('Successfully updated workitem files with pull request URLs'); - } else { - console.log('No workitem files were updated'); + // Skip if no repository URL + if (!project.repoUrl) { + console.log(`Skipping project ${project.name}: No repository URL found`); + return { + project, + processedWorkitems: [] + }; + } + + try { + // Get credentials for the project + const credentials = this.getCredentialsForProject(project); + + // Clone the project repository + console.log(`Cloning project repository: ${project.repoUrl}`); + const projectRepoPath = await this.repositoryService.cloneProjectRepository(project, credentials); + + // Create a GeminiProjectProcessor to handle the project + const geminiProjectProcessor = new GeminiProjectProcessor( + project, + projectRepoPath, + mainRepoPath + ); + + // Let Gemini operate within the project + console.log(`Letting Gemini operate within project: ${project.name}`); + const result = await geminiProjectProcessor.processProject(); + + // If no workitems were processed or there was an error, return early + if (result.processedWorkitems.length === 0 || result.error) { + console.log(`No workitems processed for project ${project.name}`); + return result; + } + + // Skip creating commits/PRs if dry run is enabled + if (DRY_RUN_SKIP_COMMITS) { + console.log(`[DRY RUN] Skipping commit and PR creation for project ${project.name}`); + return { + ...result, + pullRequestUrl: 'https://example.com/mock-pr-url (DRY RUN)' + }; + } + + // Create a new branch for changes + const branchName = `update-workitems-${new Date().toISOString().split('T')[0]}`; + await this.repositoryService.createBranch(projectRepoPath, branchName); + + // Commit changes + await this.repositoryService.commitChanges( + projectRepoPath, + `Update workitems: ${new Date().toISOString().split('T')[0]}` + ); + + // Push changes + await this.repositoryService.pushChanges(projectRepoPath, branchName, credentials); + + // Create pull request + const pullRequestUrl = await this.pullRequestService.createPullRequest( + project, + branchName, + result.processedWorkitems, + credentials, + result.gitPatch + ); + + console.log(`Created pull request: ${pullRequestUrl}`); + + return { + ...result, + pullRequestUrl + }; + } catch (error) { + console.error(`Error processing project ${project.name}:`, error); + return { + project, + processedWorkitems: [], + error: error instanceof Error ? error.message : String(error) + }; + } } - } - - /** - * Process a single project - * @param project Project information - * @param mainRepoPath Path to the main repository - * @returns Process result - */ - async processProject(project: Project, mainRepoPath: string): Promise { - console.log(`Processing project: ${project.name}`); - - // Skip if no repository URL - if (!project.repoUrl) { - console.log(`Skipping project ${project.name}: No repository URL found`); - return { - project, - processedWorkitems: [] - }; - } - - try { - // Get credentials for the project - const credentials = this.getCredentialsForProject(project); - - // Clone the project repository - console.log(`Cloning project repository: ${project.repoUrl}`); - const projectRepoPath = await this.repositoryService.cloneProjectRepository(project, credentials); - - // Create a GeminiProjectProcessor to handle the project - const geminiProjectProcessor = new GeminiProjectProcessor( - project, - projectRepoPath, - mainRepoPath - ); - - // Let Gemini operate within the project - console.log(`Letting Gemini operate within project: ${project.name}`); - const result = await geminiProjectProcessor.processProject(); - - // If no workitems were processed or there was an error, return early - if (result.processedWorkitems.length === 0 || result.error) { - console.log(`No workitems processed for project ${project.name}`); - return result; - } - - // Skip creating commits/PRs if dry run is enabled - if (DRY_RUN_SKIP_COMMITS) { - console.log(`[DRY RUN] Skipping commit and PR creation for project ${project.name}`); - return { - ...result, - pullRequestUrl: 'https://example.com/mock-pr-url (DRY RUN)' - }; - } - - // Create a new branch for changes - const branchName = `update-workitems-${new Date().toISOString().split('T')[0]}`; - await this.repositoryService.createBranch(projectRepoPath, branchName); - - // Commit changes - await this.repositoryService.commitChanges( - projectRepoPath, - `Update workitems: ${new Date().toISOString().split('T')[0]}` - ); - - // Push changes - await this.repositoryService.pushChanges(projectRepoPath, branchName, credentials); - - // Create pull request - const pullRequestUrl = await this.pullRequestService.createPullRequest( - project, - branchName, - result.processedWorkitems, - credentials, - result.gitPatch - ); - - console.log(`Created pull request: ${pullRequestUrl}`); - - return { - ...result, - pullRequestUrl - }; - } catch (error) { - console.error(`Error processing project ${project.name}:`, error); - return { - project, - processedWorkitems: [], - error: error instanceof Error ? error.message : String(error) - }; - } - } } diff --git a/src/functions/prompts-to-test-spec/src/services/pull-request-service.ts b/src/functions/prompts-to-test-spec/src/services/pull-request-service.ts index a16f1ee..a5e903c 100644 --- a/src/functions/prompts-to-test-spec/src/services/pull-request-service.ts +++ b/src/functions/prompts-to-test-spec/src/services/pull-request-service.ts @@ -3,163 +3,176 @@ */ import axios from 'axios'; import * as path from 'path'; -import { Project, RepoCredentials, Workitem } from '../types'; -import { GeminiService } from './gemini-service'; +import {Project, RepoCredentials, Workitem} from '../types'; +import {GeminiService} from './gemini-service'; export class PullRequestService { - private geminiService: GeminiService; + private geminiService: GeminiService; - constructor() { - this.geminiService = new GeminiService(); - } - /** - * Create a pull request for changes in a repository - * @param project Project information - * @param branchName Name of the branch with changes - * @param processedWorkitems List of processed workitems - * @param credentials Repository credentials - * @returns URL of the created pull request - */ - async createPullRequest( - project: Project, - branchName: string, - processedWorkitems: { workitem: Workitem; success: boolean; error?: string; status?: 'skipped' | 'updated' | 'created'; filesWritten?: string[] }[], - credentials: RepoCredentials, - gitPatch?: string - ): Promise { - if (!project.repoHost || !project.repoUrl) { - throw new Error(`Repository information not found for project ${project.name}`); + constructor() { + this.geminiService = new GeminiService(); } - // Generate PR title and description - const title = `Update workitems: ${new Date().toISOString().split('T')[0]}`; - const description = await this.generatePullRequestDescription(processedWorkitems, gitPatch); + /** + * Create a pull request for changes in a repository + * @param project Project information + * @param branchName Name of the branch with changes + * @param processedWorkitems List of processed workitems + * @param credentials Repository credentials + * @returns URL of the created pull request + */ + async createPullRequest( + project: Project, + branchName: string, + processedWorkitems: { + workitem: Workitem; + success: boolean; + error?: string; + status?: 'skipped' | 'updated' | 'created'; + filesWritten?: string[] + }[], + credentials: RepoCredentials, + gitPatch?: string + ): Promise { + if (!project.repoHost || !project.repoUrl) { + throw new Error(`Repository information not found for project ${project.name}`); + } - // Determine the repository host type and create PR accordingly - if (project.repoHost.includes('github.com')) { - return this.createGithubPullRequest(project, branchName, title, description, credentials); - } else if (project.repoHost.includes('gitea')) { - return this.createGiteaPullRequest(project, branchName, title, description, credentials); - } else { - throw new Error(`Unsupported repository host: ${project.repoHost}`); - } - } + // Generate PR title and description + const title = `Update workitems: ${new Date().toISOString().split('T')[0]}`; + const description = await this.generatePullRequestDescription(processedWorkitems, gitPatch); - /** - * Create a pull request on GitHub - * @param project Project information - * @param branchName Name of the branch with changes - * @param title Pull request title - * @param description Pull request description - * @param credentials Repository credentials - * @returns URL of the created pull request - */ - private async createGithubPullRequest( - project: Project, - branchName: string, - title: string, - description: string, - credentials: RepoCredentials - ): Promise { - // Extract owner and repo from the repository URL - const repoUrlParts = project.repoUrl!.split('/'); - const repo = path.basename(repoUrlParts[repoUrlParts.length - 1], '.git'); - const owner = repoUrlParts[repoUrlParts.length - 2]; - - // Create the pull request - const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls`; - - const headers: Record = { - 'Accept': 'application/vnd.github.v3+json', - }; - - if (credentials.type === 'token' && credentials.token) { - headers['Authorization'] = `token ${credentials.token}`; - } else if (credentials.type === 'username-password' && credentials.username && credentials.password) { - const auth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); - headers['Authorization'] = `Basic ${auth}`; - } else { - throw new Error('Invalid credentials for GitHub'); + // Determine the repository host type and create PR accordingly + if (project.repoHost.includes('github.com')) { + return this.createGithubPullRequest(project, branchName, title, description, credentials); + } else if (project.repoHost.includes('gitea')) { + return this.createGiteaPullRequest(project, branchName, title, description, credentials); + } else { + throw new Error(`Unsupported repository host: ${project.repoHost}`); + } } - const response = await axios.post( - apiUrl, - { - title, - body: description, - head: branchName, - base: project.targetBranch || 'main', // Use target branch from project info or default to 'main' - }, - { headers } - ); + /** + * Create a pull request on GitHub + * @param project Project information + * @param branchName Name of the branch with changes + * @param title Pull request title + * @param description Pull request description + * @param credentials Repository credentials + * @returns URL of the created pull request + */ + private async createGithubPullRequest( + project: Project, + branchName: string, + title: string, + description: string, + credentials: RepoCredentials + ): Promise { + // Extract owner and repo from the repository URL + const repoUrlParts = project.repoUrl!.split('/'); + const repo = path.basename(repoUrlParts[repoUrlParts.length - 1], '.git'); + const owner = repoUrlParts[repoUrlParts.length - 2]; - return response.data.html_url; - } + // Create the pull request + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls`; - /** - * Create a pull request on Gitea - * @param project Project information - * @param branchName Name of the branch with changes - * @param title Pull request title - * @param description Pull request description - * @param credentials Repository credentials - * @returns URL of the created pull request - */ - private async createGiteaPullRequest( - project: Project, - branchName: string, - title: string, - description: string, - credentials: RepoCredentials - ): Promise { - // Extract owner and repo from the repository URL - const repoUrlParts = project.repoUrl!.split('/'); - const repo = path.basename(repoUrlParts[repoUrlParts.length - 1], '.git'); - const owner = repoUrlParts[repoUrlParts.length - 2]; + const headers: Record = { + 'Accept': 'application/vnd.github.v3+json', + }; - // Create the pull request - const apiUrl = `${project.repoHost}/api/v1/repos/${owner}/${repo}/pulls`; + if (credentials.type === 'token' && credentials.token) { + headers['Authorization'] = `token ${credentials.token}`; + } else if (credentials.type === 'username-password' && credentials.username && credentials.password) { + const auth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); + headers['Authorization'] = `Basic ${auth}`; + } else { + throw new Error('Invalid credentials for GitHub'); + } - const headers: Record = { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }; + const response = await axios.post( + apiUrl, + { + title, + body: description, + head: branchName, + base: project.targetBranch || 'main', // Use target branch from project info or default to 'main' + }, + {headers} + ); - if (credentials.type === 'token' && credentials.token) { - headers['Authorization'] = `token ${credentials.token}`; - } else if (credentials.type === 'username-password' && credentials.username && credentials.password) { - const auth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); - headers['Authorization'] = `Basic ${auth}`; - } else { - throw new Error('Invalid credentials for Gitea'); + return response.data.html_url; } - const response = await axios.post( - apiUrl, - { - title, - body: description, - head: branchName, - base: project.targetBranch || 'main', // Use target branch from project info or default to 'main' - }, - { headers } - ); + /** + * Create a pull request on Gitea + * @param project Project information + * @param branchName Name of the branch with changes + * @param title Pull request title + * @param description Pull request description + * @param credentials Repository credentials + * @returns URL of the created pull request + */ + private async createGiteaPullRequest( + project: Project, + branchName: string, + title: string, + description: string, + credentials: RepoCredentials + ): Promise { + // Extract owner and repo from the repository URL + const repoUrlParts = project.repoUrl!.split('/'); + const repo = path.basename(repoUrlParts[repoUrlParts.length - 1], '.git'); + const owner = repoUrlParts[repoUrlParts.length - 2]; - return response.data.html_url; - } + // Create the pull request + const apiUrl = `${project.repoHost}/api/v1/repos/${owner}/${repo}/pulls`; - /** - * Generate a description for the pull request using Gemini - * @param processedWorkitems List of processed workitems - * @param gitPatch Optional git patch to include in the description - * @returns Pull request description - */ - private async generatePullRequestDescription( - processedWorkitems: { workitem: Workitem; success: boolean; error?: string; status?: 'skipped' | 'updated' | 'created'; filesWritten?: string[] }[], - gitPatch?: string - ): Promise { - // Use Gemini to generate the pull request description, passing the git patch - // so Gemini can analyze the code changes - return await this.geminiService.generatePullRequestDescription(processedWorkitems, gitPatch); - } + const headers: Record = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }; + + if (credentials.type === 'token' && credentials.token) { + headers['Authorization'] = `token ${credentials.token}`; + } else if (credentials.type === 'username-password' && credentials.username && credentials.password) { + const auth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); + headers['Authorization'] = `Basic ${auth}`; + } else { + throw new Error('Invalid credentials for Gitea'); + } + + const response = await axios.post( + apiUrl, + { + title, + body: description, + head: branchName, + base: project.targetBranch || 'main', // Use target branch from project info or default to 'main' + }, + {headers} + ); + + return response.data.html_url; + } + + /** + * Generate a description for the pull request using Gemini + * @param processedWorkitems List of processed workitems + * @param gitPatch Optional git patch to include in the description + * @returns Pull request description + */ + private async generatePullRequestDescription( + processedWorkitems: { + workitem: Workitem; + success: boolean; + error?: string; + status?: 'skipped' | 'updated' | 'created'; + filesWritten?: string[] + }[], + gitPatch?: string + ): Promise { + // Use Gemini to generate the pull request description, passing the git patch + // so Gemini can analyze the code changes + return await this.geminiService.generatePullRequestDescription(processedWorkitems, gitPatch); + } } diff --git a/src/prompts/nitro-back/AI.md b/src/prompts/nitro-back/AI.md index 0a319c7..ebc9e81 100644 --- a/src/prompts/nitro-back/AI.md +++ b/src/prompts/nitro-back/AI.md @@ -5,6 +5,7 @@ - Add comments in the feature file indicating - The date/time/execution info of the job that created the work item - The work item prompt file in this directory +- Cumcumber spec should be succinct and deterministic. Avoid words like "should" and "should have", prefer "must" and "must have". - Inactive work items should have their feature file deleted. - Updates should be committed to a new branch and a pull request should be created.s - The pull request should include a short description of the modified code diff --git a/src/prompts/nitro-back/workitems/2025-06-08-test.md b/src/prompts/nitro-back/workitems/2025-06-08-test.md index d01b0aa..5d16ef6 100644 --- a/src/prompts/nitro-back/workitems/2025-06-08-test.md +++ b/src/prompts/nitro-back/workitems/2025-06-08-test.md @@ -7,3 +7,9 @@ The nitro-back backend should have a /test endpoint implemented returning the js - [ ] Jira: - [ ] Implementation: - [x] Active + + +### Log + +2025-06-08T03:00:46.571Z - Workitem has been implemented. Created files: +No files were affected.