This commit is contained in:
cghislai 2025-06-08 09:20:47 +02:00
parent 6568ff640d
commit 21379adf96
21 changed files with 8019 additions and 2 deletions

View File

@ -4,6 +4,7 @@
<exclude-output />
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/src/functions/shared/coverage" />
<excludeFolder url="file://$MODULE_DIR$/src/functions/test-spec-to-test-implementation/coverage" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

View File

@ -1,3 +1,4 @@
node_modules/
dist/
.env
coverage/

View File

@ -0,0 +1,26 @@
# Main repository configuration
MAIN_REPO_URL=https://github.com/Ebitda-SRL/test-ai-code-agents.git
MAIN_REPO_TOKEN=your_token_here
# MAIN_REPO_USERNAME=your_username
# MAIN_REPO_PASSWORD=your_password
# GitHub credentials
GITHUB_TOKEN=your_github_token
# GITHUB_USERNAME=your_github_username
# GITHUB_PASSWORD=your_github_password
# Gitea credentials
GITEA_USERNAME=your_gitea_username
GITEA_PASSWORD=your_gitea_password
# Google Cloud configuration
GOOGLE_CLOUD_PROJECT_ID=your-project-id
GOOGLE_CLOUD_LOCATION=us-central1
GEMINI_MODEL=gemini-1.5-pro
GOOGLE_API_KEY=your_api_key
# Function configuration
DEBUG=false
USE_LOCAL_REPO=true
DRY_RUN_SKIP_GEMINI=true
DRY_RUN_SKIP_COMMITS=true

View File

@ -0,0 +1,4 @@
node_modules/
dist/
.env
coverage/

View File

@ -0,0 +1,28 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
testPathIgnorePatterns: ['<rootDir>/src/__tests__/setup.ts'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
collectCoverage: true,
coverageDirectory: 'coverage',
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/__tests__/**',
'!src/**/__mocks__/**',
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
setupFiles: ['<rootDir>/src/__tests__/setup.ts'],
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
{
"name": "test-spec-to-test-implementation",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"prestart": "npm run build",
"deploy": "gcloud functions deploy testSpecToTestImplementationHttp --gen2 --runtime=nodejs20 --source=. --trigger-http --allow-unauthenticated",
"deploy:event": "gcloud functions deploy testSpecToTestImplementationEvent --gen2 --runtime=nodejs20 --source=. --trigger-event=google.cloud.storage.object.v1.finalized --trigger-resource=YOUR_BUCKET_NAME",
"clean": "rm -rf dist",
"test": "jest",
"test:watch": "jest --watch",
"dev": "npm run build && functions-framework --target=testSpecToTestImplementationHttp --port=18081",
"dev:watch": "concurrently \"tsc -w\" \"nodemon --watch dist/ --exec functions-framework --target=testSpecToTestImplementationHttp --port=18081\"",
"dev:event": "npm run build && functions-framework --target=testSpecToTestImplementationEvent --signature-type=event"
},
"main": "dist/index.js",
"dependencies": {
"@google-cloud/functions-framework": "^3.0.0",
"@google-cloud/vertexai": "^0.5.0",
"axios": "^1.6.7",
"dotenv": "^16.4.5",
"shared-functions": "file:../shared",
"simple-git": "^3.23.0"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.30",
"concurrently": "^8.2.2",
"jest": "^29.7.0",
"nodemon": "^3.0.3",
"ts-jest": "^29.1.2",
"typescript": "^5.8.3"
},
"engines": {
"node": ">=20"
},
"files": [
"dist"
]
}

View File

@ -0,0 +1,284 @@
import { formatHttpResponse } from '../index';
import { ProcessResult, HttpResponse } from '../types';
import { ProcessorService } from '../services/processor-service';
// Mock the ProcessorService
jest.mock('../services/processor-service', () => {
const mockProcessProjects = jest.fn();
const mockProcessorInstance = {
processProjects: mockProcessProjects
};
return {
ProcessorService: jest.fn().mockImplementation(() => mockProcessorInstance)
};
});
describe('formatHttpResponse', () => {
test('should format successful results correctly', () => {
// Arrange
const results: ProcessResult[] = [
{
project: { name: 'project1', path: '/path/to/project1' },
success: true,
filesWritten: ['file1.ts', 'file2.ts'],
filesRemoved: ['file3.ts'],
pullRequestUrl: 'https://github.com/org/repo/pull/1'
},
{
project: { name: 'project2', path: '/path/to/project2' },
success: true,
filesWritten: ['file4.ts'],
filesRemoved: [],
pullRequestUrl: 'https://github.com/org/repo/pull/2'
}
];
// Act
const response: HttpResponse = formatHttpResponse(results);
// Assert
expect(response.success).toBe(true);
expect(response.projectsProcessed).toBe(2);
expect(response.projectsSucceeded).toBe(2);
expect(response.projectsFailed).toBe(0);
expect(response.mainPullRequestUrl).toBe('https://github.com/org/repo/pull/1');
expect(response.projects).toHaveLength(2);
expect(response.projects[0].name).toBe('project1');
expect(response.projects[0].success).toBe(true);
expect(response.projects[0].filesWritten).toBe(2);
expect(response.projects[0].filesRemoved).toBe(1);
expect(response.projects[0].pullRequestUrl).toBe('https://github.com/org/repo/pull/1');
expect(response.projects[1].name).toBe('project2');
expect(response.projects[1].success).toBe(true);
expect(response.projects[1].filesWritten).toBe(1);
expect(response.projects[1].filesRemoved).toBe(0);
expect(response.projects[1].pullRequestUrl).toBe('https://github.com/org/repo/pull/2');
});
test('should format results with failures correctly', () => {
// Arrange
const results: ProcessResult[] = [
{
project: { name: 'project1', path: '/path/to/project1' },
success: true,
filesWritten: ['file1.ts'],
filesRemoved: [],
pullRequestUrl: 'https://github.com/org/repo/pull/1'
},
{
project: { name: 'project2', path: '/path/to/project2' },
success: false,
error: 'Something went wrong'
}
];
// Act
const response: HttpResponse = formatHttpResponse(results);
// Assert
expect(response.success).toBe(false);
expect(response.projectsProcessed).toBe(2);
expect(response.projectsSucceeded).toBe(1);
expect(response.projectsFailed).toBe(1);
expect(response.mainPullRequestUrl).toBe('https://github.com/org/repo/pull/1');
expect(response.projects).toHaveLength(2);
expect(response.projects[0].name).toBe('project1');
expect(response.projects[0].success).toBe(true);
expect(response.projects[0].filesWritten).toBe(1);
expect(response.projects[0].filesRemoved).toBe(0);
expect(response.projects[1].name).toBe('project2');
expect(response.projects[1].success).toBe(false);
expect(response.projects[1].error).toBe('Something went wrong');
expect(response.projects[1].filesWritten).toBe(0);
expect(response.projects[1].filesRemoved).toBe(0);
});
test('should handle empty results array', () => {
// Arrange
const results: ProcessResult[] = [];
// Act
const response: HttpResponse = formatHttpResponse(results);
// Assert
expect(response.success).toBe(true);
expect(response.projectsProcessed).toBe(0);
expect(response.projectsSucceeded).toBe(0);
expect(response.projectsFailed).toBe(0);
expect(response.mainPullRequestUrl).toBeUndefined();
expect(response.projects).toHaveLength(0);
});
test('should handle undefined filesWritten and filesRemoved', () => {
// Arrange
const results: ProcessResult[] = [
{
project: { name: 'project1', path: '/path/to/project1' },
success: true
}
];
// Act
const response: HttpResponse = formatHttpResponse(results);
// Assert
expect(response.success).toBe(true);
expect(response.projectsProcessed).toBe(1);
expect(response.projectsSucceeded).toBe(1);
expect(response.projectsFailed).toBe(0);
expect(response.projects[0].filesWritten).toBe(0);
expect(response.projects[0].filesRemoved).toBe(0);
});
});
// Import the HTTP and CloudEvent handlers
import { http, cloudEvent } from '@google-cloud/functions-framework';
// Mock the functions-framework
jest.mock('@google-cloud/functions-framework', () => {
return {
http: jest.fn(),
cloudEvent: jest.fn(),
CloudEvent: jest.fn()
};
});
describe('HTTP endpoint handler', () => {
let httpHandler: Function;
let mockReq: any;
let mockRes: any;
let mockProcessorInstance: any;
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// Capture the HTTP handler function when it's registered
(http as jest.Mock).mockImplementation((name, handler) => {
httpHandler = handler;
});
// Re-import the index to trigger the HTTP handler registration
jest.isolateModules(() => {
require('../index');
});
// Create mock request and response objects
mockReq = {};
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
// Get the mock ProcessorService instance
mockProcessorInstance = new ProcessorService();
});
test('should return successful response when processing succeeds', async () => {
// Arrange
const mockResults: ProcessResult[] = [
{
project: { name: 'project1', path: '/path/to/project1' },
success: true,
filesWritten: ['file1.ts'],
pullRequestUrl: 'https://github.com/org/repo/pull/1'
}
];
mockProcessorInstance.processProjects.mockResolvedValue(mockResults);
// Act
await httpHandler(mockReq, mockRes);
// Assert
expect(mockProcessorInstance.processProjects).toHaveBeenCalledTimes(1);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
success: true,
projectsProcessed: 1,
projectsSucceeded: 1,
projectsFailed: 0
}));
});
test('should return error response when processing fails', async () => {
// Arrange
const mockError = new Error('Processing failed');
mockProcessorInstance.processProjects.mockRejectedValue(mockError);
// Act
await httpHandler(mockReq, mockRes);
// Assert
expect(mockProcessorInstance.processProjects).toHaveBeenCalledTimes(1);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
success: false,
error: 'Processing failed'
}));
});
});
describe('Cloud Event handler', () => {
let cloudEventHandler: Function;
let mockEvent: any;
let mockProcessorInstance: any;
let consoleLogSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// Spy on console methods
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
// Capture the Cloud Event handler function when it's registered
(cloudEvent as jest.Mock).mockImplementation((name, handler) => {
cloudEventHandler = handler;
});
// Re-import the index to trigger the Cloud Event handler registration
jest.isolateModules(() => {
require('../index');
});
// Create mock event object
mockEvent = { type: 'test-event' };
// Get the mock ProcessorService instance
mockProcessorInstance = new ProcessorService();
});
afterEach(() => {
// Restore console methods
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
test('should process projects successfully', async () => {
// Arrange
mockProcessorInstance.processProjects.mockResolvedValue([]);
// Act
await cloudEventHandler(mockEvent);
// Assert
expect(mockProcessorInstance.processProjects).toHaveBeenCalledTimes(1);
expect(consoleLogSpy).toHaveBeenCalledWith('Received event:', 'test-event');
expect(consoleLogSpy).toHaveBeenCalledWith('Processing completed successfully');
});
test('should handle errors and rethrow them', async () => {
// Arrange
const mockError = new Error('Processing failed');
mockProcessorInstance.processProjects.mockRejectedValue(mockError);
// Act & Assert
await expect(cloudEventHandler(mockEvent)).rejects.toThrow('Processing failed');
expect(mockProcessorInstance.processProjects).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error processing projects:', mockError);
});
});

View File

@ -0,0 +1,277 @@
import { ProcessorService } from '../../services/processor-service';
import { ProjectService } from '../../services/project-service';
import { ProjectTestSpecsService } from '../../services/project-test-specs-service';
import { ProcessResult } from '../../types';
import * as path from 'path';
// Mock the shared-functions module
jest.mock('shared-functions', () => {
return {
RepositoryService: jest.fn().mockImplementation(() => {
return {
cloneMainRepository: jest.fn().mockResolvedValue('/mock/repo/path'),
cloneProjectRepository: jest.fn().mockResolvedValue('/mock/project/repo/path'),
createBranch: jest.fn().mockResolvedValue(undefined),
commitChanges: jest.fn().mockResolvedValue(undefined),
pushChanges: jest.fn().mockResolvedValue(undefined),
generateGitPatch: jest.fn().mockResolvedValue('mock git patch')
};
}),
PullRequestService: jest.fn().mockImplementation(() => {
return {
createPullRequest: jest.fn().mockResolvedValue('https://github.com/org/repo/pull/1')
};
}),
GeminiService: jest.fn().mockImplementation(() => {
return {
generatePullRequestDescription: jest.fn().mockResolvedValue('Mock PR description')
};
})
};
});
// Mock the ProjectService
jest.mock('../../services/project-service', () => {
return {
ProjectService: jest.fn().mockImplementation(() => {
return {
findProjects: jest.fn(),
readProjectInfo: jest.fn(),
readProjectGuidelines: jest.fn()
};
})
};
});
// Mock the ProjectTestSpecsService
jest.mock('../../services/project-test-specs-service', () => {
return {
ProjectTestSpecsService: jest.fn().mockImplementation(() => {
return {
processProject: jest.fn()
};
})
};
});
// Mock the config module
jest.mock('../../config', () => {
return {
validateConfig: jest.fn(),
DRY_RUN_SKIP_COMMITS: false,
DRY_RUN_SKIP_GEMINI: false,
getGiteaCredentials: jest.fn().mockReturnValue({
type: 'token',
token: 'mock-gitea-token'
}),
getGithubCredentials: jest.fn().mockReturnValue({
type: 'token',
token: 'mock-github-token'
}),
getMainRepoCredentials: jest.fn().mockReturnValue({
type: 'token',
token: 'mock-main-repo-token'
}),
MAIN_REPO_URL: 'https://github.com/org/repo',
USE_LOCAL_REPO: false,
GOOGLE_CLOUD_PROJECT_ID: 'mock-project-id',
GOOGLE_CLOUD_LOCATION: 'mock-location',
GEMINI_MODEL: 'mock-model'
};
});
describe('ProcessorService', () => {
let processorService: ProcessorService;
let mockProjectService: jest.Mocked<ProjectService>;
let mockProjectTestSpecsService: jest.Mocked<ProjectTestSpecsService>;
let consoleLogSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// Spy on console methods
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
// Create a new instance of the service
processorService = new ProcessorService();
// Get the mock instances and set up direct access to their methods
mockProjectService = (processorService as any).projectService;
// Get the mock ProjectTestSpecsService instance
// We need to mock the constructor to return our mock instance
const mockInstance = {
processProject: jest.fn(),
projectService: {},
sharedRepositoryService: {},
processTestSpec: jest.fn(),
collectRelevantFiles: jest.fn(),
generateImplementations: jest.fn()
};
(ProjectTestSpecsService as jest.Mock).mockImplementation(() => mockInstance);
mockProjectTestSpecsService = mockInstance as unknown as jest.Mocked<ProjectTestSpecsService>;
});
afterEach(() => {
// Restore console methods
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
describe('processProjects', () => {
test('should process projects successfully', async () => {
// Arrange
const mockProjects = [
{ name: 'project1', path: '/path/to/project1', repoUrl: 'https://github.com/org/project1', repoHost: 'github.com' },
{ name: 'project2', path: '/path/to/project2', repoUrl: 'https://gitea.example.com/org/project2', repoHost: 'gitea.example.com' }
];
mockProjectService.findProjects.mockResolvedValue(mockProjects);
const mockProcessResult1: ProcessResult = {
project: mockProjects[0],
success: true,
filesWritten: ['file1.ts'],
filesRemoved: [],
gitPatch: 'mock git patch 1'
};
const mockProcessResult2: ProcessResult = {
project: mockProjects[1],
success: true,
filesWritten: ['file2.ts'],
filesRemoved: [],
gitPatch: 'mock git patch 2'
};
mockProjectTestSpecsService.processProject
.mockResolvedValueOnce(mockProcessResult1)
.mockResolvedValueOnce(mockProcessResult2);
// Act
const results = await processorService.processProjects();
// Assert
expect(results).toHaveLength(2);
expect(results[0].project.name).toBe('project1');
expect(results[0].success).toBe(true);
expect(results[0].pullRequestUrl).toBe('https://github.com/org/repo/pull/1');
expect(results[1].project.name).toBe('project2');
expect(results[1].success).toBe(true);
expect(results[1].pullRequestUrl).toBe('https://github.com/org/repo/pull/1');
});
test('should handle errors when processing projects', async () => {
// Arrange
const mockProjects = [
{ name: 'project1', path: '/path/to/project1', repoUrl: 'https://github.com/org/project1', repoHost: 'github.com' },
{ name: 'project2', path: '/path/to/project2', repoUrl: 'https://gitea.example.com/org/project2', repoHost: 'gitea.example.com' }
];
mockProjectService.findProjects.mockResolvedValue(mockProjects);
const mockProcessResult1: ProcessResult = {
project: mockProjects[0],
success: true,
filesWritten: ['file1.ts'],
filesRemoved: [],
gitPatch: 'mock git patch 1'
};
mockProjectTestSpecsService.processProject
.mockResolvedValueOnce(mockProcessResult1)
.mockRejectedValueOnce(new Error('Processing failed for project2'));
// Act
const results = await processorService.processProjects();
// Assert
expect(results).toHaveLength(2);
expect(results[0].project.name).toBe('project1');
expect(results[0].success).toBe(true);
expect(results[1].project.name).toBe('project2');
expect(results[1].success).toBe(false);
expect(results[1].error).toBe('Processing failed for project2');
});
test('should handle project with no repository URL', async () => {
// Arrange
const mockProjects = [
{ name: 'project1', path: '/path/to/project1', repoHost: 'github.com' } // No repoUrl
];
mockProjectService.findProjects.mockResolvedValue(mockProjects);
// Act
const results = await processorService.processProjects();
// Assert
expect(results).toHaveLength(1);
expect(results[0].project.name).toBe('project1');
expect(results[0].success).toBe(false);
expect(results[0].error).toBe('No repository URL found');
});
test('should handle project with unsupported repository host', async () => {
// Arrange
const mockProjects = [
{ name: 'project1', path: '/path/to/project1', repoUrl: 'https://gitlab.com/org/project1', repoHost: 'gitlab.com' }
];
mockProjectService.findProjects.mockResolvedValue(mockProjects);
// Act
const results = await processorService.processProjects();
// Assert
expect(results).toHaveLength(1);
expect(results[0].project.name).toBe('project1');
expect(results[0].success).toBe(false);
expect(results[0].error).toBe('Unsupported repository host: gitlab.com');
});
test('should skip commits when DRY_RUN_SKIP_COMMITS is true', async () => {
// This test is simpler to implement by directly testing the processProject method
// with DRY_RUN_SKIP_COMMITS set to true
// Arrange
const mockProject = {
name: 'project1',
path: '/path/to/project1',
repoUrl: 'https://github.com/org/project1',
repoHost: 'github.com'
};
const mockProcessResult: ProcessResult = {
project: mockProject,
success: true,
filesWritten: ['file1.ts'],
filesRemoved: [],
gitPatch: 'mock git patch'
};
// Mock the ProjectTestSpecsService to return a successful result
mockProjectTestSpecsService.processProject.mockResolvedValue(mockProcessResult);
// Mock the config module
const configModule = require('../../config');
const originalDryRunSkipCommits = configModule.DRY_RUN_SKIP_COMMITS;
configModule.DRY_RUN_SKIP_COMMITS = true;
try {
// Act
const result = await processorService.processProject(mockProject, '/mock/repo/path');
// Assert
expect(result.success).toBe(true);
expect(result.pullRequestUrl).toContain('(DRY RUN)');
} finally {
// Restore the original value
configModule.DRY_RUN_SKIP_COMMITS = originalDryRunSkipCommits;
}
});
});
});

View File

@ -0,0 +1,123 @@
import { ProjectService } from '../../services/project-service';
import { Project } from 'shared-functions';
// Mock the shared-functions module
jest.mock('shared-functions', () => {
return {
ProjectService: jest.fn().mockImplementation(() => {
return {
findProjects: jest.fn(),
readProjectInfo: jest.fn(),
readProjectGuidelines: jest.fn()
};
})
};
});
describe('ProjectService', () => {
let projectService: ProjectService;
let mockSharedProjectService: any;
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// Create a new instance of the service
projectService = new ProjectService();
// Get the mock SharedProjectService instance
mockSharedProjectService = (projectService as any).sharedProjectService;
});
describe('findProjects', () => {
test('should delegate to SharedProjectService.findProjects with correct parameters', async () => {
// Arrange
const promptsDir = '/path/to/prompts';
const mockProjects: Project[] = [
{ name: 'project1', path: '/path/to/project1' },
{ name: 'project2', path: '/path/to/project2' }
];
mockSharedProjectService.findProjects.mockResolvedValue(mockProjects);
// Act
const result = await projectService.findProjects(promptsDir);
// Assert
expect(mockSharedProjectService.findProjects).toHaveBeenCalledWith(
promptsDir,
'test-spec-to-test-implementation'
);
expect(result).toEqual(mockProjects);
});
test('should handle errors from SharedProjectService.findProjects', async () => {
// Arrange
const promptsDir = '/path/to/prompts';
const mockError = new Error('Failed to find projects');
mockSharedProjectService.findProjects.mockRejectedValue(mockError);
// Act & Assert
await expect(projectService.findProjects(promptsDir)).rejects.toThrow('Failed to find projects');
});
});
describe('readProjectInfo', () => {
test('should delegate to SharedProjectService.readProjectInfo with correct parameters', async () => {
// Arrange
const projectPath = '/path/to/project';
const projectName = 'project1';
const mockProject: Project = { name: projectName, path: projectPath };
mockSharedProjectService.readProjectInfo.mockResolvedValue(mockProject);
// Act
const result = await projectService.readProjectInfo(projectPath, projectName);
// Assert
expect(mockSharedProjectService.readProjectInfo).toHaveBeenCalledWith(projectPath, projectName);
expect(result).toEqual(mockProject);
});
test('should handle errors from SharedProjectService.readProjectInfo', async () => {
// Arrange
const projectPath = '/path/to/project';
const projectName = 'project1';
const mockError = new Error('Failed to read project info');
mockSharedProjectService.readProjectInfo.mockRejectedValue(mockError);
// Act & Assert
await expect(projectService.readProjectInfo(projectPath, projectName)).rejects.toThrow('Failed to read project info');
});
});
describe('readProjectGuidelines', () => {
test('should delegate to SharedProjectService.readProjectGuidelines with correct parameters', async () => {
// Arrange
const projectPath = '/path/to/project';
const mockGuidelines = 'Project guidelines content';
mockSharedProjectService.readProjectGuidelines.mockResolvedValue(mockGuidelines);
// Act
const result = await projectService.readProjectGuidelines(projectPath);
// Assert
expect(mockSharedProjectService.readProjectGuidelines).toHaveBeenCalledWith(projectPath);
expect(result).toEqual(mockGuidelines);
});
test('should handle errors from SharedProjectService.readProjectGuidelines', async () => {
// Arrange
const projectPath = '/path/to/project';
const mockError = new Error('Failed to read project guidelines');
mockSharedProjectService.readProjectGuidelines.mockRejectedValue(mockError);
// Act & Assert
await expect(projectService.readProjectGuidelines(projectPath)).rejects.toThrow('Failed to read project guidelines');
});
});
});

View File

@ -0,0 +1,311 @@
import {ProjectTestSpecsService} from '../../services/project-test-specs-service';
import {ProjectService} from '../../services/project-service';
import {ProcessResult, TestSpecImplementationStatus} from '../../types';
import * as fs from 'fs';
import * as path from 'path';
// Mock the fs module
jest.mock('fs', () => {
return {
...jest.requireActual('fs'),
existsSync: jest.fn(),
readFileSync: jest.fn(),
readdirSync: jest.fn(),
statSync: jest.fn()
};
});
// Mock the path module
jest.mock('path', () => {
const originalPath = jest.requireActual('path');
return {
...originalPath,
join: jest.fn().mockImplementation((...args) => args.join('/')),
relative: jest.fn().mockImplementation((from, to) => to.replace(from + '/', ''))
};
});
// Mock the ProjectService
jest.mock('../../services/project-service', () => {
return {
ProjectService: jest.fn().mockImplementation(() => {
return {
findProjects: jest.fn(),
readProjectInfo: jest.fn(),
readProjectGuidelines: jest.fn()
};
})
};
});
// Mock the shared-functions module
jest.mock('shared-functions', () => {
return {
RepositoryService: jest.fn().mockImplementation(() => {
return {
generateGitPatch: jest.fn().mockResolvedValue('mock git patch')
};
}),
GeminiFileSystemService: jest.fn().mockImplementation(() => {
return {
processModelStream: jest.fn()
};
})
};
});
// Mock the config module
jest.mock('../../config', () => {
return {
DRY_RUN_SKIP_GEMINI: false,
GOOGLE_CLOUD_PROJECT_ID: 'mock-project-id',
GOOGLE_CLOUD_LOCATION: 'mock-location',
GEMINI_MODEL: 'mock-model'
};
});
describe('ProjectTestSpecsService', () => {
let projectTestSpecsService: ProjectTestSpecsService;
let mockProjectService: jest.Mocked<ProjectService>;
let mockSharedRepositoryService: any;
let mockGeminiFileSystemService: any;
let consoleLogSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// Spy on console methods
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
// Create a new instance of the service
projectTestSpecsService = new ProjectTestSpecsService();
// Get the mock instances
mockProjectService = (projectTestSpecsService as any).projectService;
mockSharedRepositoryService = (projectTestSpecsService as any).sharedRepositoryService;
// Mock the GeminiFileSystemService
const {GeminiFileSystemService} = require('shared-functions');
mockGeminiFileSystemService = new GeminiFileSystemService();
});
afterEach(() => {
// Restore console methods
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
describe('processProject', () => {
test('should process project successfully', async () => {
// Arrange
const project = {name: 'project1', path: '/path/to/project1'};
const projectRepoPath = '/path/to/project/repo';
const projectGuidelines = 'Project guidelines content';
mockProjectService.readProjectGuidelines.mockResolvedValue(projectGuidelines);
// Mock the generateImplementation method
const mockImplementationResult = {
text: 'Generated implementation',
decision: {decision: 'create' as TestSpecImplementationStatus, reason: 'New test spec'},
filesWritten: ['file1.ts', 'file2.ts'],
filesDeleted: []
};
// Mock the private methods using spyOn
jest.spyOn(projectTestSpecsService as any, 'processTestSpec').mockResolvedValue({
project,
success: true,
filesWritten: mockImplementationResult.filesWritten,
filesRemoved: mockImplementationResult.filesDeleted
});
mockSharedRepositoryService.generateGitPatch.mockResolvedValue('mock git patch');
// Act
const result = await projectTestSpecsService.processProject(project, projectRepoPath);
// Assert
expect(mockProjectService.readProjectGuidelines).toHaveBeenCalledWith(project.path);
expect(result.success).toBe(true);
expect(result.filesWritten).toEqual(mockImplementationResult.filesWritten);
expect(result.filesRemoved).toEqual(mockImplementationResult.filesDeleted);
expect(result.gitPatch).toBe('mock git patch');
});
test('should handle errors when processing project', async () => {
// Arrange
const project = {name: 'project1', path: '/path/to/project1'};
const projectRepoPath = '/path/to/project/repo';
mockProjectService.readProjectGuidelines.mockRejectedValue(new Error('Failed to read guidelines'));
// Act
const result = await projectTestSpecsService.processProject(project, projectRepoPath);
// Assert
expect(result.success).toBe(false);
expect(result.error).toBe('Failed to read guidelines');
});
test('should handle errors when generating git patch', async () => {
// Arrange
const project = {name: 'project1', path: '/path/to/project1'};
const projectRepoPath = '/path/to/project/repo';
const projectGuidelines = 'Project guidelines content';
mockProjectService.readProjectGuidelines.mockResolvedValue(projectGuidelines);
// Mock the processTestSpec method
jest.spyOn(projectTestSpecsService as any, 'processTestSpec').mockResolvedValue({
project,
success: true,
filesWritten: ['file1.ts'],
filesRemoved: []
});
mockSharedRepositoryService.generateGitPatch.mockRejectedValue(new Error('Failed to generate git patch'));
// Act
const result = await projectTestSpecsService.processProject(project, projectRepoPath);
// Assert
expect(result.success).toBe(true);
expect(result.filesWritten).toEqual(['file1.ts']);
expect(result.gitPatch).toBeUndefined();
});
});
describe('collectRelevantFiles', () => {
test('should collect relevant files from project directory', async () => {
// Arrange
const project = {name: 'project1', path: '/path/to/project1'};
const projectRepoPath = '/path/to/project/repo';
// Mock fs.existsSync to return true for specific files
(fs.existsSync as jest.Mock).mockImplementation((filePath) => {
if (filePath.includes('nitro-it/src/test/java/be/fiscalteam/nitro/bdd')) return true;
return filePath.includes('INFO.md') || filePath.includes('README.md');
});
// Mock fs.readFileSync to return file content
(fs.readFileSync as jest.Mock).mockImplementation((filePath) => {
if (filePath.includes('INFO.md')) return 'INFO.md content';
if (filePath.includes('README.md')) return 'README.md content';
return '';
});
// Act
const result = await (projectTestSpecsService as any).collectRelevantFiles(project, projectRepoPath);
// Assert
expect(Object.keys(result)).toContain('INFO.md');
expect(Object.keys(result)).toContain('README.md');
expect(result['INFO.md']).toBe('INFO.md content');
expect(result['README.md']).toBe('README.md content');
});
test('should handle errors when collecting relevant files', async () => {
// Arrange
const project = {name: 'project1', path: '/path/to/project1'};
const projectRepoPath = '/path/to/project/repo';
// Mock fs.existsSync to throw an error
(fs.existsSync as jest.Mock).mockImplementation(() => {
throw new Error('File system error');
});
// Act
const result = await (projectTestSpecsService as any).collectRelevantFiles(project, projectRepoPath);
// Assert
expect(result).toEqual({});
expect(consoleErrorSpy).toHaveBeenCalled();
});
});
describe('generateImplementation', () => {
test('should generate implementation using Gemini', async () => {
// Arrange
const projectRepoPath = '/path/to/project/repo';
const guidelines = 'Project guidelines content';
const relevantFiles = {
'INFO.md': 'INFO.md content',
'README.md': 'README.md content'
};
// Mock GeminiFileSystemService.processModelStream
const mockProcessModelStreamResult = {
text: 'Generated implementation',
decision: {decision: 'create', reason: 'New test spec'},
filesWritten: ['file1.ts', 'file2.ts'],
filesDeleted: []
};
mockGeminiFileSystemService.processModelStream.mockResolvedValue(mockProcessModelStreamResult);
// Mock the GeminiFileSystemService constructor
const {GeminiFileSystemService} = require('shared-functions');
GeminiFileSystemService.mockImplementation(() => mockGeminiFileSystemService);
// Act
const result = await (projectTestSpecsService as any).generateImplementations(
projectRepoPath,
guidelines,
relevantFiles
);
// Assert
expect(mockGeminiFileSystemService.processModelStream).toHaveBeenCalledWith(
guidelines,
expect.stringContaining('INFO.md'),
projectRepoPath
);
expect(result.text).toBe('Generated implementation');
expect(result.decision.decision).toBe('create');
expect(result.filesWritten).toEqual(['file1.ts', 'file2.ts']);
expect(result.filesDeleted).toEqual([]);
});
test('should return mock implementation when DRY_RUN_SKIP_GEMINI is true', async () => {
// Arrange
const projectRepoPath = '/path/to/project/repo';
const guidelines = 'Project guidelines content';
const relevantFiles = {
'INFO.md': 'INFO.md content',
'README.md': 'README.md content'
};
// Mock the config module to set DRY_RUN_SKIP_GEMINI to true
jest.resetModules();
jest.doMock('../../config', () => {
return {
DRY_RUN_SKIP_GEMINI: true,
GOOGLE_CLOUD_PROJECT_ID: 'mock-project-id',
GOOGLE_CLOUD_LOCATION: 'mock-location',
GEMINI_MODEL: 'mock-model'
};
});
// Re-import the service to get the updated config
const {ProjectTestSpecsService} = require('../../services/project-test-specs-service');
const serviceWithDryRun = new ProjectTestSpecsService();
// Act
const result = await (serviceWithDryRun).generateImplementations(
projectRepoPath,
guidelines,
relevantFiles
);
// Assert
expect(result.text).toContain('DRY RUN');
expect(result.decision.decision).toBe('create');
expect(result.filesWritten).toEqual([]);
expect(result.filesDeleted).toEqual([]);
});
});
});

View File

@ -0,0 +1,16 @@
/**
* Jest setup file
*
* This file is executed before each test file is run.
* It can be used to set up global test environment configurations.
*/
// Suppress console output during tests
global.console = {
...console,
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
};

View File

@ -0,0 +1,116 @@
/**
* Configuration module for loading environment variables
*/
import * as dotenv from 'dotenv';
import * as path from 'path';
// Load environment variables from .env file
dotenv.config({ path: path.resolve(__dirname, '../.env') });
// Main repository configuration
export const MAIN_REPO_URL = process.env.MAIN_REPO_URL || '';
export const MAIN_REPO_TOKEN = process.env.MAIN_REPO_TOKEN;
export const MAIN_REPO_USERNAME = process.env.MAIN_REPO_USERNAME;
export const MAIN_REPO_PASSWORD = process.env.MAIN_REPO_PASSWORD;
// GitHub credentials
export const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
export const GITHUB_USERNAME = process.env.GITHUB_USERNAME;
export const GITHUB_PASSWORD = process.env.GITHUB_PASSWORD;
// Gitea credentials
export const GITEA_USERNAME = process.env.GITEA_USERNAME;
export const GITEA_PASSWORD = process.env.GITEA_PASSWORD;
// Google Cloud configuration
export const GOOGLE_CLOUD_PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT_ID || '';
export const GOOGLE_CLOUD_LOCATION = process.env.GOOGLE_CLOUD_LOCATION || 'us-central1';
export const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-1.5-pro';
export const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY;
// Function configuration
export const DEBUG = process.env.DEBUG === 'true';
export const USE_LOCAL_REPO = process.env.USE_LOCAL_REPO === 'true';
export const DRY_RUN_SKIP_GEMINI = process.env.DRY_RUN_SKIP_GEMINI === 'true';
export const DRY_RUN_SKIP_COMMITS = process.env.DRY_RUN_SKIP_COMMITS === 'true';
// Validate required configuration
export function validateConfig(): void {
const missingVars: string[] = [];
// Only check for main repo URL and credentials if not using local repo
if (!USE_LOCAL_REPO) {
if (!MAIN_REPO_URL) {
missingVars.push('MAIN_REPO_URL');
}
if (!MAIN_REPO_TOKEN && (!MAIN_REPO_USERNAME || !MAIN_REPO_PASSWORD)) {
missingVars.push('MAIN_REPO_TOKEN or MAIN_REPO_USERNAME/MAIN_REPO_PASSWORD');
}
}
if (!GOOGLE_CLOUD_PROJECT_ID) {
missingVars.push('GOOGLE_CLOUD_PROJECT_ID');
}
if (missingVars.length > 0) {
throw new Error(`Missing required environment variables: ${missingVars.join(', ')}`);
}
}
// Get repository credentials for the main repository
export function getMainRepoCredentials(): { type: 'username-password' | 'token'; username?: string; password?: string; token?: string } {
if (USE_LOCAL_REPO) {
// Return dummy credentials when using local repo
return {
type: 'token',
token: 'dummy-token-for-local-repo'
};
}
if (MAIN_REPO_TOKEN) {
return {
type: 'token',
token: MAIN_REPO_TOKEN
};
} else if (MAIN_REPO_USERNAME && MAIN_REPO_PASSWORD) {
return {
type: 'username-password',
username: MAIN_REPO_USERNAME,
password: MAIN_REPO_PASSWORD
};
}
throw new Error('No credentials available for the main repository');
}
// Get GitHub credentials
export function getGithubCredentials(): { type: 'username-password' | 'token'; username?: string; password?: string; token?: string } | undefined {
if (GITHUB_TOKEN) {
return {
type: 'token',
token: GITHUB_TOKEN
};
} else if (GITHUB_USERNAME && GITHUB_PASSWORD) {
return {
type: 'username-password',
username: GITHUB_USERNAME,
password: GITHUB_PASSWORD
};
}
return undefined;
}
// Get Gitea credentials
export function getGiteaCredentials(): { type: 'username-password'; username: string; password: string } | undefined {
if (GITEA_USERNAME && GITEA_PASSWORD) {
return {
type: 'username-password',
username: GITEA_USERNAME,
password: GITEA_PASSWORD
};
}
return undefined;
}

View File

@ -0,0 +1,86 @@
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';
// Validate configuration on startup
try {
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
}
/**
* Format process results into a concise HTTP response
* @param results Process results from the processor service
* @returns Formatted HTTP response
*/
export function formatHttpResponse(results: ProcessResult[]): HttpResponse {
// Count successful and failed projects
const projectsSucceeded = results.filter(r => !r.error).length;
const projectsFailed = results.filter(r => !!r.error).length;
// Find main PR URL if any
const mainPullRequestUrl = results.find(r => r.pullRequestUrl)?.pullRequestUrl;
// Format project summaries
const projects: ProjectSummary[] = results.map(result => {
return {
name: result.project.name,
success: result.success ?? false,
error: result.error,
filesWritten: result.filesWritten?.length ?? 0,
filesRemoved: result.filesRemoved?.length ?? 0,
pullRequestUrl: result.pullRequestUrl,
};
});
return {
success: projectsFailed === 0,
projectsProcessed: results.length,
projectsSucceeded,
projectsFailed,
mainPullRequestUrl,
projects
};
}
/**
* HTTP endpoint for the test-spec-to-test-implementation function
*/
http('testSpecToTestImplementationHttp', async (req, res): Promise<void> => {
try {
const processor = new ProcessorService();
const results = await processor.processProjects();
const response = formatHttpResponse(results);
res.status(200).json(response);
} catch (error) {
console.error('Error processing projects:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
res.status(500).json({
success: false,
projectsProcessed: 0,
projectsSucceeded: 0,
projectsFailed: 1,
projects: [],
error: errorMessage
});
}
});
/**
* Cloud Event handler for the test-spec-to-test-implementation function
*/
cloudEvent('testSpecToTestImplementationEvent', async (event: CloudEvent<any>): Promise<void> => {
try {
console.log('Received event:', event.type);
const processor = new ProcessorService();
await processor.processProjects();
console.log('Processing completed successfully');
} catch (error) {
console.error('Error processing projects:', error);
throw error;
}
});

View File

@ -0,0 +1,263 @@
/**
* Service for orchestrating the entire process
*/
import * as path from 'path';
import * as os from 'os';
import {ProcessResult, RepoCredentials} from '../types';
import {
RepositoryService as SharedRepositoryService,
PullRequestService as SharedPullRequestService,
GeminiService, Project
} from 'shared-functions';
import {ProjectService} from './project-service';
import {ProjectTestSpecsService} from './project-test-specs-service';
import {
DRY_RUN_SKIP_COMMITS,
getGiteaCredentials,
getGithubCredentials,
getMainRepoCredentials,
MAIN_REPO_URL,
USE_LOCAL_REPO,
validateConfig,
GOOGLE_CLOUD_PROJECT_ID,
GOOGLE_CLOUD_LOCATION,
GEMINI_MODEL,
DRY_RUN_SKIP_GEMINI
} from '../config';
export class ProcessorService {
private sharedRepositoryService: SharedRepositoryService;
private projectService: ProjectService;
private sharedPullRequestService: SharedPullRequestService;
private geminiService: GeminiService;
private mainRepoUrl: string;
private mainRepoCredentials: RepoCredentials;
private giteaCredentials?: RepoCredentials;
private githubCredentials?: RepoCredentials;
constructor() {
// Validate configuration
validateConfig();
// Initialize services
const repoBaseDir = path.join(os.tmpdir(), 'test-spec-to-test-implementation');
this.sharedRepositoryService = new SharedRepositoryService(repoBaseDir);
this.projectService = new ProjectService();
this.sharedPullRequestService = new SharedPullRequestService();
this.geminiService = new GeminiService(
GOOGLE_CLOUD_PROJECT_ID,
GOOGLE_CLOUD_LOCATION,
GEMINI_MODEL,
DRY_RUN_SKIP_GEMINI
);
// 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<ProcessResult[]> {
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 test-spec-to-test-implementation directory and navigate up if needed
const currentDir = process.cwd();
if (currentDir.endsWith('test-spec-to-test-implementation')) {
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.sharedRepositoryService.cloneMainRepository(
this.mainRepoUrl,
this.mainRepoCredentials
);
}
// Find all projects in the test-spec-to-test-implementation directory
const promptsDir = path.join(mainRepoPath, 'src', 'prompts', 'test-spec-to-test-implementation');
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 test-spec-to-test-implementation 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,
success: false,
error: error instanceof Error ? error.message : String(error)
});
}
}
console.log(`Finished processing all ${projects.length} projects`);
return results;
} catch (error) {
console.error('Error processing projects:', error);
throw 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<ProcessResult> {
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,
success: false,
error: "No repository URL found"
};
}
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.sharedRepositoryService.cloneProjectRepository(project, credentials);
// Create a ProjectTestSpecsService to handle the project
const projectTestSpecsService = new ProjectTestSpecsService();
// Process test specs within the project
console.log(`Processing test specs within project: ${project.name}`);
const result = await projectTestSpecsService.processProject(project, projectRepoPath);
// If no test specs were processed or there was an error, return early
if (!result.success || result.error) {
console.error(`Failure for project ${project.name}: ${result.error}`);
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,
success: true,
pullRequestUrl: 'https://example.com/mock-pr-url (DRY RUN)'
};
}
// Create a new branch for changes
const branchName = `implement-test-specs-${new Date().toISOString().split('T')[0]}`;
await this.sharedRepositoryService.createBranch(projectRepoPath, branchName);
// Commit changes
await this.sharedRepositoryService.commitChanges(
projectRepoPath,
`Implement test specs: ${new Date().toISOString().split('T')[0]}`
);
// Push changes
await this.sharedRepositoryService.pushChanges(projectRepoPath, branchName, credentials);
// Generate PR description using Gemini
const description = await this.geminiService.generatePullRequestDescription(
"Test spec implementation",
result.gitPatch
);
// Generate PR title
const title = `Implement test specs: ${new Date().toISOString().split('T')[0]}`;
// Create pull request
const pullRequestUrl = await this.sharedPullRequestService.createPullRequest(
project,
branchName,
credentials,
title,
description
);
console.log(`Created pull request: ${pullRequestUrl}`);
return {
...result,
success: true,
pullRequestUrl
};
} catch (error) {
console.error(`Error processing project ${project.name}:`, error);
return {
project,
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
}

View File

@ -0,0 +1,40 @@
/**
* Service for handling project operations
*/
import {Project, ProjectService as SharedProjectService} from 'shared-functions';
export class ProjectService {
private sharedProjectService: SharedProjectService;
constructor() {
this.sharedProjectService = new SharedProjectService();
}
/**
* Find all projects in the test-spec-to-test-implementation directory
* @param promptsDir Path to the test-spec-to-test-implementation directory
* @returns Array of projects
*/
async findProjects(promptsDir: string): Promise<Project[]> {
return this.sharedProjectService.findProjects(promptsDir, 'test-spec-to-test-implementation');
}
/**
* Read project information from INFO.md
* @param projectPath Path to the project directory
* @param projectName Name of the project
* @returns Project information
*/
async readProjectInfo(projectPath: string, projectName: string): Promise<Project> {
return this.sharedProjectService.readProjectInfo(projectPath, projectName);
}
/**
* Read AI guidelines for a project
* @param projectPath Path to the project directory
* @returns AI guidelines content
*/
async readProjectGuidelines(projectPath: string): Promise<string> {
return this.sharedProjectService.readProjectGuidelines(projectPath);
}
}

View File

@ -0,0 +1,215 @@
/**
* Service for handling test spec operations within a project
*/
import * as fs from 'fs';
import * as path from 'path';
import {ProcessResult, TestSpecImplementationStatus} from '../types';
import {ProjectService} from './project-service';
import {DRY_RUN_SKIP_GEMINI} from '../config';
import {GeminiFileSystemService, Project, RepositoryService as SharedRepositoryService,} from 'shared-functions';
export class ProjectTestSpecsService {
private projectService: ProjectService;
private sharedRepositoryService: SharedRepositoryService;
constructor() {
this.projectService = new ProjectService();
this.sharedRepositoryService = new SharedRepositoryService(
path.join(require('os').tmpdir(), 'test-spec-to-test-implementation')
);
}
/**
* Process the project test specs
* @param project Project to process
* @param projectRepoPath Path to the project repository
* @returns Process result
*/
async processProject(project: Project, projectRepoPath: string): Promise<ProcessResult> {
console.log(`ProjectTestSpecsService: Processing project ${project.name}`);
try {
// Read project guidelines
const projectGuidelines = await this.projectService.readProjectGuidelines(project.path);
const result = await this.processTestSpec(project, projectRepoPath, projectGuidelines);
// Generate git patch if any files were written
let gitPatch: string | undefined = undefined;
if ((result.filesWritten?.length ?? 0) > 0) {
try {
console.log(`Generating git patch for project ${project.name} with ${result.filesWritten} files written`);
gitPatch = await this.sharedRepositoryService.generateGitPatch(projectRepoPath);
} catch (error) {
console.error(`Error generating git patch for project ${project.name}:`, error);
}
}
return {
...result,
gitPatch
};
} catch (error) {
console.error(`Error processing project ${project.name}:`, error);
return {
project: project,
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Process a test spec using Gemini
* @param project Project containing the test spec
* @param projectRepoPath Path to the project repository
* @param testSpec Test spec to process
* @param projectGuidelines Project guidelines
* @returns Result of the processing
*/
private async processTestSpec(
project: Project,
projectRepoPath: string,
projectGuidelines: string
): Promise<ProcessResult> {
try {
// Collect all relevant files from the project directory
const relevantFiles = await this.collectRelevantFiles(project, projectRepoPath);
// Let Gemini generate the implementation
const result = await this.generateImplementations(
projectRepoPath,
projectGuidelines,
relevantFiles
);
// Check status consistency
if (result.decision?.decision === 'skip') {
if (result.filesWritten.length > 0) {
throw new Error(`Skip decision with files written: ${result.filesWritten.join(', ')}`);
}
if (result.filesDeleted.length > 0) {
throw new Error(`Skip decision with files deleted: ${result.filesDeleted.join(', ')}`);
}
} else if (result.decision?.decision === 'create' || result.decision?.decision === 'update') {
if (result.filesWritten.length === 0) {
throw new Error(`${result.decision.decision} decision with no files written`);
}
}
console.log(`ProjectTestSpecsService: Completed processing project (Status: ${result.decision?.decision}, Files written: ${result.filesWritten.length})`);
return {
project: project,
success: true,
filesWritten: result.filesWritten,
filesRemoved: result.filesDeleted,
};
} catch (error) {
console.error(`Error processing project ${project.name}:`, error);
return {
project: project,
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Collect relevant files from the project directory
* @param project The project info
* @param projectRepoPath Path to the project repository
* @param testSpec The test spec being processed (for logging purposes)
* @returns Object containing file contents
*/
private async collectRelevantFiles(project: Project, projectRepoPath: string): Promise<Record<string, string>> {
const relevantFiles: Record<string, string> = {};
try {
// Add project guidelines
const guidelinePaths = project.aiGuidelines?.split(',') ?? [
'INFO.md', 'README.md', 'GUIDELINES.md', 'ARCHITECTURE.md', 'IMPLEMENTATION.md'
];
guidelinePaths
.map(g => g.trim())
.forEach(fileName => {
const filePath = path.join(projectRepoPath, fileName);
if (fs.existsSync(filePath)) {
relevantFiles[fileName] = fs.readFileSync(filePath, 'utf-8');
}
});
console.log(`ProjectTestSpecsService: Collected ${Object.keys(relevantFiles).length} relevant files for ${project.name}`);
} catch (error) {
console.error(`Error collecting relevant files for ${project.name}:`, error);
}
return relevantFiles;
}
/**
* Generate implementation using Gemini API
* @param projectRepoPath Path to the project repository
* @param guidelines Project guidelines
* @param testSpec Test spec to implement
* @param relevantFiles Additional relevant files to include in the prompt
* @returns Object containing the generated text, parsed decision, and files written/deleted
*/
private async generateImplementations(
projectRepoPath: string,
guidelines: string,
relevantFiles: Record<string, string> = {}
): Promise<{
text: string;
decision?: { decision: TestSpecImplementationStatus; reason: string };
filesWritten: string[];
filesDeleted: string[];
}> {
const currentDate = new Date().toISOString();
// If dry run is enabled, return a mock implementation
if (DRY_RUN_SKIP_GEMINI) {
const mockText = `# Generated by test-spec-to-test-implementation on ${currentDate} (DRY RUN)`;
return {
text: mockText,
decision: {
decision: 'create',
reason: 'This is a mock decision for dry run mode'
},
filesWritten: [],
filesDeleted: []
};
}
// Import required configuration
const {GOOGLE_CLOUD_PROJECT_ID, GOOGLE_CLOUD_LOCATION, GEMINI_MODEL} = require('../config');
// Initialize the GeminiFileSystemService directly
const geminiFileSystemService = new GeminiFileSystemService(
GOOGLE_CLOUD_PROJECT_ID,
GOOGLE_CLOUD_LOCATION,
GEMINI_MODEL,
DRY_RUN_SKIP_GEMINI
);
const additionalContent = relevantFiles ?? {};
let contentString = ""
for (const [filename, content] of Object.entries(additionalContent)) {
contentString += `\n--- ${filename} ---\n${content}\n`;
}
// Process the model stream
const result = await geminiFileSystemService.processModelStream(
guidelines,
contentString,
projectRepoPath
);
return {
text: result.text,
decision: result.decision as { decision: TestSpecImplementationStatus; reason: string },
filesWritten: result.filesWritten,
filesDeleted: result.filesDeleted
};
}
}

View File

@ -0,0 +1,64 @@
/**
* Type definitions for the test-spec-to-test-implementation function
*/
import {Project} from "shared-functions";
/**
* Status of a test spec implementation
*/
export type TestSpecImplementationStatus = 'create' | 'update' | 'skip';
/**
* Test specification from a feature file
*/
export interface TestSpec {
name: string;
path: string;
featureName: string;
content: string;
implementationPath?: string;
}
export interface RepoCredentials {
type: 'username-password' | 'token';
username?: string;
password?: string;
token?: string;
}
export interface ProcessResult {
project: Project;
success?: boolean;
pullRequestUrl?: string;
error?: string;
gitPatch?: string;
filesWritten?: string[];
filesRemoved?: string[];
}
/**
* HTTP response format for the API
*/
export interface HttpResponse {
success: boolean;
projectsProcessed: number;
projectsSucceeded: number;
projectsFailed: number;
mainPullRequestUrl?: string;
projects: ProjectSummary[];
error?: string;
}
/**
* Summary of a project's processing results
*/
export interface ProjectSummary {
name: string;
success: boolean;
error?: string;
filesWritten: number;
filesRemoved: number;
pullRequestUrl?: string;
}

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@ -3,7 +3,7 @@
Nitro backend server in quarkus
- [x] Repo host: https://gitea.fteamdev.valuya.be/
- [x] Repo url: https://gitea.fteamdev.valuya.be/fiscalteam/nitro-back.git
- [x] Repo url: https://gitea.fteamdev.valuya.be/cghislai/nitro-back.git
- [x] Target branch: main
- [ ] AI guidelines:
- [x] Jira component: nitro

View File

@ -3,7 +3,7 @@
Nitro backend server in quarkus
- [x] Repo host: https://gitea.fteamdev.valuya.be/
- [x] Repo url: https://gitea.fteamdev.valuya.be/fiscalteam/nitro-back.git
- [x] Repo url: https://gitea.fteamdev.valuya.be/cghislai/nitro-back.git
- [x] Target branch: main
- [ ] AI guidelines:
- [x] Jira component: nitro