WIP
This commit is contained in:
parent
f32c78a94b
commit
d6ddd8aa45
@ -1,2 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.env
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -35,4 +35,5 @@ export interface ProcessResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}[];
|
}[];
|
||||||
pullRequestUrl?: string;
|
pullRequestUrl?: string;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user