This commit is contained in:
cghislai 2025-06-08 01:20:00 +02:00
parent f32c78a94b
commit d6ddd8aa45
9 changed files with 268 additions and 139 deletions

View File

@ -1,2 +1,3 @@
node_modules/
dist/
.env

View File

@ -29,23 +29,27 @@ export const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-1.5-pro';
// Function configuration
export const DEBUG = process.env.DEBUG === 'true';
export const USE_LOCAL_REPO = process.env.USE_LOCAL_REPO === 'true';
// Validate required configuration
export function validateConfig(): void {
const missingVars: string[] = [];
if (!MAIN_REPO_URL) {
missingVars.push('MAIN_REPO_URL');
// 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 (!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(', ')}`);
}
@ -53,6 +57,14 @@ export function validateConfig(): void {
// 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',
@ -65,7 +77,7 @@ export function getMainRepoCredentials(): { type: 'username-password' | 'token';
password: MAIN_REPO_PASSWORD
};
}
throw new Error('No credentials available for the main repository');
}
@ -83,7 +95,7 @@ export function getGithubCredentials(): { type: 'username-password' | 'token'; u
password: GITHUB_PASSWORD
};
}
return undefined;
}
@ -96,6 +108,6 @@ export function getGiteaCredentials(): { type: 'username-password'; username: st
password: GITEA_PASSWORD
};
}
return undefined;
}

View File

@ -6,7 +6,7 @@ import { validateConfig } from './config';
try {
validateConfig();
} catch (error) {
console.error('Configuration error:', error.message);
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
}

View File

@ -8,17 +8,17 @@ jest.mock('path');
describe('ProjectService', () => {
let projectService: ProjectService;
beforeEach(() => {
projectService = new ProjectService();
// Reset all mocks
jest.resetAllMocks();
// Mock path.join to return predictable paths
(path.join as jest.Mock).mockImplementation((...args) => args.join('/'));
});
describe('findProjects', () => {
it('should find all projects in the prompts directory', async () => {
// Mock fs.readdirSync to return project directories
@ -28,12 +28,12 @@ describe('ProjectService', () => {
{ name: 'not-a-project', isDirectory: () => true },
{ name: 'README.md', isDirectory: () => false }
]);
// Mock fs.existsSync to return true for INFO.md files
(fs.existsSync as jest.Mock).mockImplementation((path: string) => {
return path.endsWith('project1/INFO.md') || path.endsWith('project2/INFO.md');
});
// Mock readProjectInfo
jest.spyOn(projectService, 'readProjectInfo').mockImplementation(async (projectPath, projectName) => {
return {
@ -44,9 +44,9 @@ describe('ProjectService', () => {
jiraComponent: projectName
};
});
const projects = await projectService.findProjects('prompts');
expect(projects).toHaveLength(2);
expect(projects[0].name).toBe('project1');
expect(projects[1].name).toBe('project2');
@ -56,21 +56,21 @@ describe('ProjectService', () => {
expect(fs.existsSync).toHaveBeenCalledWith('prompts/not-a-project/INFO.md');
});
});
describe('readProjectInfo', () => {
it('should read project information from INFO.md', async () => {
const infoContent = `# Project Name
- [x] Repo host: https://github.com
- [x] Repo url: https://github.com/org/project.git
- [x] Jira component: project-component
`;
// Mock fs.readFileSync to return INFO.md content
(fs.readFileSync as jest.Mock).mockReturnValueOnce(infoContent);
const project = await projectService.readProjectInfo('path/to/project', 'project');
expect(project).toEqual({
name: 'project',
path: 'path/to/project',
@ -80,20 +80,42 @@ describe('ProjectService', () => {
});
expect(fs.readFileSync).toHaveBeenCalledWith('path/to/project/INFO.md', 'utf-8');
});
it('should handle project information that does not follow the expected format', async () => {
const infoContent = `# Project Name
This is a project description.
Some other content that doesn't match the expected format.
`;
// Mock fs.readFileSync to return malformed INFO.md content
(fs.readFileSync as jest.Mock).mockReturnValueOnce(infoContent);
const project = await projectService.readProjectInfo('path/to/project', 'project');
expect(project).toEqual({
name: 'project',
path: 'path/to/project',
repoHost: undefined,
repoUrl: undefined,
jiraComponent: undefined
});
expect(fs.readFileSync).toHaveBeenCalledWith('path/to/project/INFO.md', 'utf-8');
});
});
describe('findWorkitems', () => {
it('should find all workitems in a project', async () => {
// Mock fs.existsSync to return true for workitems directory
(fs.existsSync as jest.Mock).mockReturnValueOnce(true);
// Mock fs.readdirSync to return workitem files
(fs.readdirSync as jest.Mock).mockReturnValueOnce([
'workitem1.md',
'workitem2.md',
'not-a-workitem.txt'
]);
// Mock readWorkitemInfo
jest.spyOn(projectService, 'readWorkitemInfo').mockImplementation(async (workitemPath, fileName) => {
return {
@ -106,32 +128,32 @@ describe('ProjectService', () => {
isActive: true
};
});
const workitems = await projectService.findWorkitems('path/to/project');
expect(workitems).toHaveLength(2);
expect(workitems[0].name).toBe('workitem1');
expect(workitems[1].name).toBe('workitem2');
expect(fs.existsSync).toHaveBeenCalledWith('path/to/project/workitems');
expect(fs.readdirSync).toHaveBeenCalledWith('path/to/project/workitems');
});
it('should return empty array if workitems directory does not exist', async () => {
// Mock fs.existsSync to return false for workitems directory
(fs.existsSync as jest.Mock).mockReturnValueOnce(false);
const workitems = await projectService.findWorkitems('path/to/project');
expect(workitems).toHaveLength(0);
expect(fs.existsSync).toHaveBeenCalledWith('path/to/project/workitems');
expect(fs.readdirSync).not.toHaveBeenCalled();
});
});
describe('readWorkitemInfo', () => {
it('should read workitem information from markdown file', async () => {
const workitemContent = `## Workitem Title
This is a description of the workitem.
It has multiple lines.
@ -139,12 +161,12 @@ It has multiple lines.
- [ ] Implementation:
- [x] Active
`;
// Mock fs.readFileSync to return workitem content
(fs.readFileSync as jest.Mock).mockReturnValueOnce(workitemContent);
const workitem = await projectService.readWorkitemInfo('path/to/workitem.md', 'workitem.md');
expect(workitem).toEqual({
name: 'workitem',
path: 'path/to/workitem.md',
@ -156,48 +178,48 @@ It has multiple lines.
});
expect(fs.readFileSync).toHaveBeenCalledWith('path/to/workitem.md', 'utf-8');
});
it('should handle workitem without Active checkbox', async () => {
const workitemContent = `## Workitem Title
This is a description of the workitem.
- [ ] Jira: JIRA-123
- [ ] Implementation:
`;
// Mock fs.readFileSync to return workitem content
(fs.readFileSync as jest.Mock).mockReturnValueOnce(workitemContent);
const workitem = await projectService.readWorkitemInfo('path/to/workitem.md', 'workitem.md');
expect(workitem.isActive).toBe(true);
});
});
describe('readProjectGuidelines', () => {
it('should read AI guidelines for a project', async () => {
const guidelinesContent = '## Guidelines\n\nThese are the guidelines.';
// Mock fs.existsSync to return true for AI.md
(fs.existsSync as jest.Mock).mockReturnValueOnce(true);
// Mock fs.readFileSync to return guidelines content
(fs.readFileSync as jest.Mock).mockReturnValueOnce(guidelinesContent);
const guidelines = await projectService.readProjectGuidelines('path/to/project');
expect(guidelines).toBe(guidelinesContent);
expect(fs.existsSync).toHaveBeenCalledWith('path/to/project/AI.md');
expect(fs.readFileSync).toHaveBeenCalledWith('path/to/project/AI.md', 'utf-8');
});
it('should return empty string if AI.md does not exist', async () => {
// Mock fs.existsSync to return false for AI.md
(fs.existsSync as jest.Mock).mockReturnValueOnce(false);
const guidelines = await projectService.readProjectGuidelines('path/to/project');
expect(guidelines).toBe('');
expect(fs.existsSync).toHaveBeenCalledWith('path/to/project/AI.md');
expect(fs.readFileSync).not.toHaveBeenCalled();

View File

@ -126,21 +126,16 @@ export class GeminiService {
const currentDate = new Date().toISOString();
// Send the AI.md file directly to Gemini without hardcoded instructions
const prompt = `
You are tasked with creating a Cucumber feature file based on a workitem description.
Project Guidelines:
${guidelines}
Workitem:
${workitemContent}
Create a Cucumber feature file that implements this workitem according to the guidelines.
Include the following comment at the top of the file:
Include the following comment at the top of the generated file:
# Generated by prompts-to-test-spec on ${currentDate}
# Source: ${workitemName}
The feature file should be complete and ready to use in a Cucumber test suite.
`;
const result = await generativeModel.generateContent({
@ -148,7 +143,86 @@ The feature file should be complete and ready to use in a Cucumber test suite.
});
const response = await result.response;
const generatedText = response.candidates[0].content.parts[0].text;
const generatedText = response.candidates[0]?.content?.parts[0]?.text || '';
return generatedText;
}
/**
* Generate a pull request description using Gemini API
* @param processedWorkitems List of processed workitems
* @returns Generated pull request description
*/
async generatePullRequestDescription(
processedWorkitems: { workitem: Workitem; success: boolean; error?: string }[]
): Promise<string> {
const generativeModel = this.vertexAI.getGenerativeModel({
model: this.model,
});
// Prepare workitem data for the prompt
const added: string[] = [];
const updated: string[] = [];
const deleted: string[] = [];
const failed: string[] = [];
for (const item of processedWorkitems) {
const { workitem, success, error } = item;
if (!success) {
failed.push(`- ${workitem.name}: ${error}`);
continue;
}
if (!workitem.isActive) {
deleted.push(`- ${workitem.name}`);
} else if (workitem.implementation) {
updated.push(`- ${workitem.name}`);
} else {
added.push(`- ${workitem.name}`);
}
}
// Create a structured summary of changes for Gemini
let workitemSummary = '';
if (added.length > 0) {
workitemSummary += 'Added workitems:\n' + added.join('\n') + '\n\n';
}
if (updated.length > 0) {
workitemSummary += 'Updated workitems:\n' + updated.join('\n') + '\n\n';
}
if (deleted.length > 0) {
workitemSummary += 'Deleted workitems:\n' + deleted.join('\n') + '\n\n';
}
if (failed.length > 0) {
workitemSummary += 'Failed workitems:\n' + failed.join('\n') + '\n\n';
}
const prompt = `
You are tasked with creating a pull request description for changes to test specifications.
The following is a summary of the changes made:
${workitemSummary}
Create a clear, professional pull request description that:
1. Explains that this PR was automatically generated by the prompts-to-test-spec function
2. Summarizes the changes (added, updated, deleted, and failed workitems)
3. Uses markdown formatting for better readability
4. Keeps the description concise but informative
The pull request description should be ready to use without further editing.
`;
const result = await generativeModel.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
});
const response = await result.response;
const generatedText = response.candidates[0]?.content?.parts[0]?.text || '';
return generatedText;
}

View File

@ -15,7 +15,8 @@ import {
getGiteaCredentials,
GOOGLE_CLOUD_PROJECT_ID,
GOOGLE_CLOUD_LOCATION,
GEMINI_MODEL
GEMINI_MODEL,
USE_LOCAL_REPO
} from '../config';
export class ProcessorService {
@ -42,9 +43,15 @@ export class ProcessorService {
);
this.pullRequestService = new PullRequestService();
// Get main repository URL and credentials
this.mainRepoUrl = MAIN_REPO_URL;
this.mainRepoCredentials = getMainRepoCredentials();
// 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();
@ -84,12 +91,19 @@ export class ProcessorService {
const results: ProcessResult[] = [];
try {
// Clone the main repository
console.log(`Cloning main repository: ${this.mainRepoUrl}`);
const mainRepoPath = await this.repositoryService.cloneMainRepository(
this.mainRepoUrl,
this.mainRepoCredentials
);
// Use local repository or clone the main repository
let mainRepoPath: string;
if (USE_LOCAL_REPO) {
console.log('Using local repository path');
mainRepoPath = path.resolve(__dirname, '../../..');
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');
@ -98,10 +112,23 @@ export class ProcessorService {
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);
console.log(`Finished processing project: ${project.name}`);
results.push(result);
} catch (error) {
console.error(`Error processing project ${project.name}:`, error);
@ -112,6 +139,7 @@ export class ProcessorService {
});
}
}
console.log(`Finished processing all ${projects.length} projects`);
return results;
} catch (error) {

View File

@ -13,25 +13,41 @@ export class ProjectService {
*/
async findProjects(promptsDir: string): Promise<Project[]> {
const projects: Project[] = [];
console.log(`ProjectService: Searching for projects in ${promptsDir}`);
// Check if prompts directory exists
if (!fs.existsSync(promptsDir)) {
console.log(`ProjectService: Directory does not exist: ${promptsDir}`);
return projects;
}
// Get all directories in the prompts directory
const entries = fs.readdirSync(promptsDir, { withFileTypes: true });
const projectDirs = entries.filter(entry => entry.isDirectory());
console.log(`ProjectService: Found ${projectDirs.length} potential project directories`);
for (const dir of projectDirs) {
const projectPath = path.join(promptsDir, dir.name);
const infoPath = path.join(projectPath, 'INFO.md');
console.log(`ProjectService: Checking directory: ${dir.name}`);
// Skip directories without INFO.md
if (!fs.existsSync(infoPath)) {
console.log(`ProjectService: Skipping ${dir.name} - no INFO.md file found`);
continue;
}
console.log(`ProjectService: Found INFO.md in ${dir.name}, reading project info`);
// Read project info
const project = await this.readProjectInfo(projectPath, dir.name);
projects.push(project);
console.log(`ProjectService: Added project: ${project.name}`);
}
return projects;
}
@ -43,20 +59,28 @@ export class ProjectService {
*/
async readProjectInfo(projectPath: string, projectName: string): Promise<Project> {
const infoPath = path.join(projectPath, 'INFO.md');
console.log(`ProjectService: Reading project info from ${infoPath}`);
const infoContent = fs.readFileSync(infoPath, 'utf-8');
// Parse INFO.md content
const repoHostMatch = infoContent.match(/- \[[ x]\] Repo host: (.*)/);
const repoUrlMatch = infoContent.match(/- \[[ x]\] Repo url: (.*)/);
const jiraComponentMatch = infoContent.match(/- \[[ x]\] Jira component: (.*)/);
return {
const project = {
name: projectName,
path: projectPath,
repoHost: repoHostMatch ? repoHostMatch[1].trim() : undefined,
repoUrl: repoUrlMatch ? repoUrlMatch[1].trim() : undefined,
jiraComponent: jiraComponentMatch ? jiraComponentMatch[1].trim() : undefined
};
console.log(`ProjectService: Project info for ${projectName}:`);
console.log(` - Repository host: ${project.repoHost || 'Not found'}`);
console.log(` - Repository URL: ${project.repoUrl || 'Not found'}`);
console.log(` - Jira component: ${project.jiraComponent || 'Not found'}`);
return project;
}
/**
@ -67,22 +91,22 @@ export class ProjectService {
async findWorkitems(projectPath: string): Promise<Workitem[]> {
const workitems: Workitem[] = [];
const workitemsDir = path.join(projectPath, 'workitems');
// Skip if workitems directory doesn't exist
if (!fs.existsSync(workitemsDir)) {
return workitems;
}
// Get all markdown files in the workitems directory
const files = fs.readdirSync(workitemsDir)
.filter(file => file.endsWith('.md'));
for (const file of files) {
const workitemPath = path.join(workitemsDir, file);
const workitem = await this.readWorkitemInfo(workitemPath, file);
workitems.push(workitem);
}
return workitems;
}
@ -94,19 +118,19 @@ export class ProjectService {
*/
async readWorkitemInfo(workitemPath: string, fileName: string): Promise<Workitem> {
const content = fs.readFileSync(workitemPath, 'utf-8');
// Parse workitem content
const titleMatch = content.match(/## (.*)/);
const jiraMatch = content.match(/- \[[ x]\] Jira: (.*)/);
const implementationMatch = content.match(/- \[[ x]\] Implementation: (.*)/);
const activeMatch = content.match(/- \[([x ])\] Active/);
// Extract description (everything between title and first metadata line)
let description = '';
const lines = content.split('\n');
let titleIndex = -1;
let metadataIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (titleIndex === -1 && lines[i].startsWith('## ')) {
titleIndex = i;
@ -114,15 +138,15 @@ export class ProjectService {
metadataIndex = i;
}
}
if (titleIndex !== -1 && metadataIndex !== -1) {
description = lines.slice(titleIndex + 1, metadataIndex).join('\n').trim();
}
// Determine if workitem is active
// If the Active checkbox is missing, assume it's active
const isActive = activeMatch ? activeMatch[1] === 'x' : true;
return {
name: fileName.replace('.md', ''),
path: workitemPath,
@ -141,11 +165,11 @@ export class ProjectService {
*/
async readProjectGuidelines(projectPath: string): Promise<string> {
const aiPath = path.join(projectPath, 'AI.md');
if (!fs.existsSync(aiPath)) {
return '';
}
return fs.readFileSync(aiPath, 'utf-8');
}
}

View File

@ -4,8 +4,14 @@
import axios from 'axios';
import * as path from 'path';
import { Project, RepoCredentials, Workitem } from '../types';
import { GeminiService } from './gemini-service';
export class PullRequestService {
private geminiService: GeminiService;
constructor() {
this.geminiService = new GeminiService();
}
/**
* Create a pull request for changes in a repository
* @param project Project information
@ -26,7 +32,7 @@ export class PullRequestService {
// Generate PR title and description
const title = `Update workitems: ${new Date().toISOString().split('T')[0]}`;
const description = this.generatePullRequestDescription(processedWorkitems);
const description = await this.generatePullRequestDescription(processedWorkitems);
// Determine the repository host type and create PR accordingly
if (project.repoHost.includes('github.com')) {
@ -61,11 +67,11 @@ export class PullRequestService {
// Create the pull request
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls`;
const headers: Record<string, string> = {
'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) {
@ -112,12 +118,12 @@ export class PullRequestService {
// Create the pull request
const apiUrl = `${project.repoHost}/api/v1/repos/${owner}/${repo}/pulls`;
const headers: Record<string, string> = {
'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) {
@ -142,53 +148,14 @@ export class PullRequestService {
}
/**
* Generate a description for the pull request
* Generate a description for the pull request using Gemini
* @param processedWorkitems List of processed workitems
* @returns Pull request description
*/
private generatePullRequestDescription(
private async generatePullRequestDescription(
processedWorkitems: { workitem: Workitem; success: boolean; error?: string }[]
): string {
const added: string[] = [];
const updated: string[] = [];
const deleted: string[] = [];
const failed: string[] = [];
for (const item of processedWorkitems) {
const { workitem, success, error } = item;
if (!success) {
failed.push(`- ${workitem.name}: ${error}`);
continue;
}
if (!workitem.isActive) {
deleted.push(`- ${workitem.name}`);
} else if (workitem.implementation) {
updated.push(`- ${workitem.name}`);
} else {
added.push(`- ${workitem.name}`);
}
}
let description = 'This PR was automatically generated by the prompts-to-test-spec function.\n\n';
if (added.length > 0) {
description += '## Added\n' + added.join('\n') + '\n\n';
}
if (updated.length > 0) {
description += '## Updated\n' + updated.join('\n') + '\n\n';
}
if (deleted.length > 0) {
description += '## Deleted\n' + deleted.join('\n') + '\n\n';
}
if (failed.length > 0) {
description += '## Failed\n' + failed.join('\n') + '\n\n';
}
return description;
): Promise<string> {
// Use Gemini to generate the pull request description
return await this.geminiService.generatePullRequestDescription(processedWorkitems);
}
}

View File

@ -35,4 +35,5 @@ export interface ProcessResult {
error?: string;
}[];
pullRequestUrl?: string;
error?: string;
}