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/ node_modules/
dist/ dist/
.env

View File

@ -29,23 +29,27 @@ export const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-1.5-pro';
// Function configuration // Function configuration
export const DEBUG = process.env.DEBUG === 'true'; export const DEBUG = process.env.DEBUG === 'true';
export const USE_LOCAL_REPO = process.env.USE_LOCAL_REPO === 'true';
// Validate required configuration // Validate required configuration
export function validateConfig(): void { export function validateConfig(): void {
const missingVars: string[] = []; const missingVars: string[] = [];
if (!MAIN_REPO_URL) { // Only check for main repo URL and credentials if not using local repo
missingVars.push('MAIN_REPO_URL'); 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) { if (!GOOGLE_CLOUD_PROJECT_ID) {
missingVars.push('GOOGLE_CLOUD_PROJECT_ID'); missingVars.push('GOOGLE_CLOUD_PROJECT_ID');
} }
if (missingVars.length > 0) { if (missingVars.length > 0) {
throw new Error(`Missing required environment variables: ${missingVars.join(', ')}`); throw new Error(`Missing required environment variables: ${missingVars.join(', ')}`);
} }
@ -53,6 +57,14 @@ export function validateConfig(): void {
// Get repository credentials for the main repository // Get repository credentials for the main repository
export function getMainRepoCredentials(): { type: 'username-password' | 'token'; username?: string; password?: string; token?: string } { 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) { if (MAIN_REPO_TOKEN) {
return { return {
type: 'token', type: 'token',
@ -65,7 +77,7 @@ export function getMainRepoCredentials(): { type: 'username-password' | 'token';
password: MAIN_REPO_PASSWORD password: MAIN_REPO_PASSWORD
}; };
} }
throw new Error('No credentials available for the main repository'); 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 password: GITHUB_PASSWORD
}; };
} }
return undefined; return undefined;
} }
@ -96,6 +108,6 @@ export function getGiteaCredentials(): { type: 'username-password'; username: st
password: GITEA_PASSWORD password: GITEA_PASSWORD
}; };
} }
return undefined; return undefined;
} }

View File

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

View File

@ -126,21 +126,16 @@ export class GeminiService {
const currentDate = new Date().toISOString(); const currentDate = new Date().toISOString();
// Send the AI.md file directly to Gemini without hardcoded instructions
const prompt = ` const prompt = `
You are tasked with creating a Cucumber feature file based on a workitem description.
Project Guidelines:
${guidelines} ${guidelines}
Workitem: Workitem:
${workitemContent} ${workitemContent}
Create a Cucumber feature file that implements this workitem according to the guidelines. Include the following comment at the top of the generated file:
Include the following comment at the top of the file:
# Generated by prompts-to-test-spec on ${currentDate} # Generated by prompts-to-test-spec on ${currentDate}
# Source: ${workitemName} # Source: ${workitemName}
The feature file should be complete and ready to use in a Cucumber test suite.
`; `;
const result = await generativeModel.generateContent({ 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 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; return generatedText;
} }

View File

@ -15,7 +15,8 @@ import {
getGiteaCredentials, getGiteaCredentials,
GOOGLE_CLOUD_PROJECT_ID, GOOGLE_CLOUD_PROJECT_ID,
GOOGLE_CLOUD_LOCATION, GOOGLE_CLOUD_LOCATION,
GEMINI_MODEL GEMINI_MODEL,
USE_LOCAL_REPO
} from '../config'; } from '../config';
export class ProcessorService { export class ProcessorService {
@ -42,9 +43,15 @@ export class ProcessorService {
); );
this.pullRequestService = new PullRequestService(); this.pullRequestService = new PullRequestService();
// Get main repository URL and credentials // Get main repository URL and credentials only if not using local repo
this.mainRepoUrl = MAIN_REPO_URL; if (!USE_LOCAL_REPO) {
this.mainRepoCredentials = getMainRepoCredentials(); this.mainRepoUrl = MAIN_REPO_URL;
this.mainRepoCredentials = getMainRepoCredentials();
} else {
// Set dummy values when using local repo
this.mainRepoUrl = '';
this.mainRepoCredentials = getMainRepoCredentials();
}
// Initialize other credentials // Initialize other credentials
this.githubCredentials = getGithubCredentials(); this.githubCredentials = getGithubCredentials();
@ -84,12 +91,19 @@ export class ProcessorService {
const results: ProcessResult[] = []; const results: ProcessResult[] = [];
try { try {
// Clone the main repository // Use local repository or clone the main repository
console.log(`Cloning main repository: ${this.mainRepoUrl}`); let mainRepoPath: string;
const mainRepoPath = await this.repositoryService.cloneMainRepository( if (USE_LOCAL_REPO) {
this.mainRepoUrl, console.log('Using local repository path');
this.mainRepoCredentials 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 // Find all projects in the prompts directory
const promptsDir = path.join(mainRepoPath, 'src', 'prompts'); const promptsDir = path.join(mainRepoPath, 'src', 'prompts');
@ -98,10 +112,23 @@ export class ProcessorService {
console.log(`Found ${projects.length} projects`); 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 // Process each project
console.log('Starting to process projects...');
for (const project of projects) { for (const project of projects) {
try { try {
console.log(`Starting processing of project: ${project.name}`);
const result = await this.processProject(project); const result = await this.processProject(project);
console.log(`Finished processing project: ${project.name}`);
results.push(result); results.push(result);
} catch (error) { } catch (error) {
console.error(`Error processing project ${project.name}:`, 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; return results;
} catch (error) { } catch (error) {

View File

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

View File

@ -4,8 +4,14 @@
import axios from 'axios'; import axios from 'axios';
import * as path from 'path'; import * as path from 'path';
import { Project, RepoCredentials, Workitem } from '../types'; import { Project, RepoCredentials, Workitem } from '../types';
import { GeminiService } from './gemini-service';
export class PullRequestService { export class PullRequestService {
private geminiService: GeminiService;
constructor() {
this.geminiService = new GeminiService();
}
/** /**
* Create a pull request for changes in a repository * Create a pull request for changes in a repository
* @param project Project information * @param project Project information
@ -26,7 +32,7 @@ export class PullRequestService {
// Generate PR title and description // Generate PR title and description
const title = `Update workitems: ${new Date().toISOString().split('T')[0]}`; 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 // Determine the repository host type and create PR accordingly
if (project.repoHost.includes('github.com')) { if (project.repoHost.includes('github.com')) {
@ -61,11 +67,11 @@ export class PullRequestService {
// Create the pull request // Create the pull request
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls`; const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls`;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Accept': 'application/vnd.github.v3+json', 'Accept': 'application/vnd.github.v3+json',
}; };
if (credentials.type === 'token' && credentials.token) { if (credentials.type === 'token' && credentials.token) {
headers['Authorization'] = `token ${credentials.token}`; headers['Authorization'] = `token ${credentials.token}`;
} else if (credentials.type === 'username-password' && credentials.username && credentials.password) { } else if (credentials.type === 'username-password' && credentials.username && credentials.password) {
@ -112,12 +118,12 @@ export class PullRequestService {
// Create the pull request // Create the pull request
const apiUrl = `${project.repoHost}/api/v1/repos/${owner}/${repo}/pulls`; const apiUrl = `${project.repoHost}/api/v1/repos/${owner}/${repo}/pulls`;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
if (credentials.type === 'token' && credentials.token) { if (credentials.type === 'token' && credentials.token) {
headers['Authorization'] = `token ${credentials.token}`; headers['Authorization'] = `token ${credentials.token}`;
} else if (credentials.type === 'username-password' && credentials.username && credentials.password) { } 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 * @param processedWorkitems List of processed workitems
* @returns Pull request description * @returns Pull request description
*/ */
private generatePullRequestDescription( private async generatePullRequestDescription(
processedWorkitems: { workitem: Workitem; success: boolean; error?: string }[] processedWorkitems: { workitem: Workitem; success: boolean; error?: string }[]
): string { ): Promise<string> {
const added: string[] = []; // Use Gemini to generate the pull request description
const updated: string[] = []; return await this.geminiService.generatePullRequestDescription(processedWorkitems);
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;
} }
} }

View File

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