From b2071030302b11a36c99a265a681dc0598043f3f Mon Sep 17 00:00:00 2001 From: cghislai Date: Sun, 8 Jun 2025 03:37:25 +0200 Subject: [PATCH] WIP --- .../__tests__/project-service-log.test.ts | 355 ++++++++++++++++++ .../src/services/gemini-project-processor.ts | 24 -- .../src/services/project-service.ts | 62 ++- .../src/services/pull-request-service.ts | 4 +- .../src/services/repository-service.ts | 22 ++ .../prompts-to-test-spec/src/types.ts | 1 + src/prompts/AI.md | 5 +- src/prompts/nitro-back/INFO.md | 1 + .../nitro-back/workitems/2025-06-08-test.md | 2 +- 9 files changed, 435 insertions(+), 41 deletions(-) create mode 100644 src/functions/prompts-to-test-spec/src/services/__tests__/project-service-log.test.ts diff --git a/src/functions/prompts-to-test-spec/src/services/__tests__/project-service-log.test.ts b/src/functions/prompts-to-test-spec/src/services/__tests__/project-service-log.test.ts new file mode 100644 index 0000000..a6d801e --- /dev/null +++ b/src/functions/prompts-to-test-spec/src/services/__tests__/project-service-log.test.ts @@ -0,0 +1,355 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ProjectService } from '../project-service'; + +// Mock fs and path modules +jest.mock('fs'); +jest.mock('path'); + +describe('ProjectService - Log Append Feature', () => { + let projectService: ProjectService; + const mockTimestamp = '2023-01-01T12:00:00.000Z'; + + 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('/')); + + // Mock Date.toISOString to return a fixed timestamp + jest.spyOn(Date.prototype, 'toISOString').mockReturnValue(mockTimestamp); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('updateWorkitemWithImplementationLog', () => { + it('should append logs to existing Log section', async () => { + const workitemContent = `## Workitem Title + +This is a description of the workitem. + +- [x] Jira: JIRA-123 +- [ ] Implementation: +- [x] Active + +### Log + +Some existing log content. +`; + + const workitem = { + name: 'workitem', + path: 'path/to/workitem.md', + title: 'Workitem Title', + description: 'This is a description of the workitem.', + jiraReference: 'JIRA-123', + implementation: '', + isActive: true + }; + + const status = 'created'; + const files = ['file1.ts', 'file2.ts']; + + // Mock fs.existsSync to return true for workitem file + (fs.existsSync as jest.Mock).mockReturnValue(true); + + // Mock fs.readFileSync to return workitem content + (fs.readFileSync as jest.Mock).mockReturnValue(workitemContent); + + // Mock fs.writeFileSync to capture the actual output + let actualContent = ''; + (fs.writeFileSync as jest.Mock).mockImplementation((path, content) => { + actualContent = content; + }); + + await projectService.updateWorkitemWithImplementationLog(workitem, status, files); + + // Verify that fs.existsSync and fs.readFileSync were called with the expected arguments + expect(fs.existsSync).toHaveBeenCalledWith('path/to/workitem.md'); + expect(fs.readFileSync).toHaveBeenCalledWith('path/to/workitem.md', 'utf-8'); + + // Verify that fs.writeFileSync was called with the path + expect(fs.writeFileSync).toHaveBeenCalledWith( + 'path/to/workitem.md', + expect.any(String), + 'utf-8' + ); + + // Get the actual content from the mock + const actualContentFromMock = (fs.writeFileSync as jest.Mock).mock.calls[0][1]; + + // Verify the complete content equality + const expectedContent = `## Workitem Title + +This is a description of the workitem. + +- [x] Jira: JIRA-123 +- [ ] Implementation: +- [x] Active + +### Log + +${mockTimestamp} - Workitem has been implemented. Created files: +- file1.ts +- file2.ts + + +Some existing log content. +`; + expect(actualContentFromMock).toEqual(expectedContent); + }); + + it('should add Log section if it does not exist', async () => { + const workitemContent = `## Workitem Title + +This is a description of the workitem. + +- [x] Jira: JIRA-123 +- [ ] Implementation: +- [x] Active + +`; + + const workitem = { + name: 'workitem', + path: 'path/to/workitem.md', + title: 'Workitem Title', + description: 'This is a description of the workitem.', + jiraReference: 'JIRA-123', + implementation: '', + isActive: true + }; + + const status = 'updated'; + const files = ['file1.ts', 'file2.ts']; + + // Mock fs.existsSync to return true for workitem file + (fs.existsSync as jest.Mock).mockReturnValue(true); + + // Mock fs.readFileSync to return workitem content + (fs.readFileSync as jest.Mock).mockReturnValue(workitemContent); + + // Mock fs.writeFileSync to capture the actual output + let actualContent = ''; + (fs.writeFileSync as jest.Mock).mockImplementation((path, content) => { + actualContent = content; + }); + + await projectService.updateWorkitemWithImplementationLog(workitem, status, files); + + // Verify that fs.existsSync and fs.readFileSync were called with the expected arguments + expect(fs.existsSync).toHaveBeenCalledWith('path/to/workitem.md'); + expect(fs.readFileSync).toHaveBeenCalledWith('path/to/workitem.md', 'utf-8'); + + // Verify that fs.writeFileSync was called with the path + expect(fs.writeFileSync).toHaveBeenCalledWith( + 'path/to/workitem.md', + expect.any(String), + 'utf-8' + ); + + // Get the actual content from the mock + const actualContentFromMock = (fs.writeFileSync as jest.Mock).mock.calls[0][1]; + + // Verify the complete content equality + const expectedContent = `## Workitem Title + +This is a description of the workitem. + +- [x] Jira: JIRA-123 +- [ ] Implementation: +- [x] Active + + + +### Log + +${mockTimestamp} - Workitem has been updated. Modified files: +- file1.ts +- file2.ts +`; + expect(actualContentFromMock).toEqual(expectedContent); + }); + + it('should handle different status types', async () => { + const workitemContent = `## Workitem Title + +This is a description of the workitem. + +- [x] Jira: JIRA-123 +- [ ] Implementation: +- [x] Active + +### Log + +Some existing log content. +`; + + const workitem = { + name: 'workitem', + path: 'path/to/workitem.md', + title: 'Workitem Title', + description: 'This is a description of the workitem.', + jiraReference: 'JIRA-123', + implementation: '', + isActive: true + }; + + const status = 'deleted'; + const files = ['file1.ts', 'file2.ts']; + + // Mock fs.existsSync to return true for workitem file + (fs.existsSync as jest.Mock).mockReturnValue(true); + + // Mock fs.readFileSync to return workitem content + (fs.readFileSync as jest.Mock).mockReturnValue(workitemContent); + + // Mock fs.writeFileSync to capture the actual output + let actualContent = ''; + (fs.writeFileSync as jest.Mock).mockImplementation((path, content) => { + actualContent = content; + }); + + await projectService.updateWorkitemWithImplementationLog(workitem, status, files); + + // Verify that fs.existsSync and fs.readFileSync were called with the expected arguments + expect(fs.existsSync).toHaveBeenCalledWith('path/to/workitem.md'); + expect(fs.readFileSync).toHaveBeenCalledWith('path/to/workitem.md', 'utf-8'); + + // Verify that fs.writeFileSync was called with the path + expect(fs.writeFileSync).toHaveBeenCalledWith( + 'path/to/workitem.md', + expect.any(String), + 'utf-8' + ); + + // Get the actual content from the mock + const actualContentFromMock = (fs.writeFileSync as jest.Mock).mock.calls[0][1]; + + // Verify the complete content equality + const expectedContent = `## Workitem Title + +This is a description of the workitem. + +- [x] Jira: JIRA-123 +- [ ] Implementation: +- [x] Active + +### Log + +${mockTimestamp} - Workitem has been deleted. Removed files: +- file1.ts +- file2.ts + + +Some existing log content. +`; + expect(actualContentFromMock).toEqual(expectedContent); + }); + + it('should handle empty files array', async () => { + const workitemContent = `## Workitem Title + +This is a description of the workitem. + +- [x] Jira: JIRA-123 +- [ ] Implementation: +- [x] Active + +### Log + +Some existing log content. +`; + + const workitem = { + name: 'workitem', + path: 'path/to/workitem.md', + title: 'Workitem Title', + description: 'This is a description of the workitem.', + jiraReference: 'JIRA-123', + implementation: '', + isActive: true + }; + + const status = 'created'; + const files: string[] = []; + + // Mock fs.existsSync to return true for workitem file + (fs.existsSync as jest.Mock).mockReturnValue(true); + + // Mock fs.readFileSync to return workitem content + (fs.readFileSync as jest.Mock).mockReturnValue(workitemContent); + + // Mock fs.writeFileSync to capture the actual output + let actualContent = ''; + (fs.writeFileSync as jest.Mock).mockImplementation((path, content) => { + actualContent = content; + }); + + await projectService.updateWorkitemWithImplementationLog(workitem, status, files); + + // Verify that fs.existsSync and fs.readFileSync were called with the expected arguments + expect(fs.existsSync).toHaveBeenCalledWith('path/to/workitem.md'); + expect(fs.readFileSync).toHaveBeenCalledWith('path/to/workitem.md', 'utf-8'); + + // Verify that fs.writeFileSync was called with the path + expect(fs.writeFileSync).toHaveBeenCalledWith( + 'path/to/workitem.md', + expect.any(String), + 'utf-8' + ); + + // Get the actual content from the mock + const actualContentFromMock = (fs.writeFileSync as jest.Mock).mock.calls[0][1]; + + // Verify the complete content equality + const expectedContent = `## Workitem Title + +This is a description of the workitem. + +- [x] Jira: JIRA-123 +- [ ] Implementation: +- [x] Active + +### Log + +${mockTimestamp} - Workitem has been implemented. Created files: +No files were affected. + + +Some existing log content. +`; + expect(actualContentFromMock).toEqual(expectedContent); + }); + + it('should throw error if workitem file does not exist', async () => { + const workitem = { + name: 'workitem', + path: 'path/to/workitem.md', + title: 'Workitem Title', + description: 'This is a description of the workitem.', + jiraReference: 'JIRA-123', + implementation: '', + isActive: true + }; + + const status = 'created'; + const files = ['file1.ts', 'file2.ts']; + + // Mock fs.existsSync to return false for workitem file + (fs.existsSync as jest.Mock).mockReturnValue(false); + + await expect(projectService.updateWorkitemWithImplementationLog(workitem, status, files)) + .rejects.toThrow('Workitem file not found: path/to/workitem.md'); + + expect(fs.existsSync).toHaveBeenCalledWith('path/to/workitem.md'); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/functions/prompts-to-test-spec/src/services/gemini-project-processor.ts b/src/functions/prompts-to-test-spec/src/services/gemini-project-processor.ts index 2a8908e..96c2925 100644 --- a/src/functions/prompts-to-test-spec/src/services/gemini-project-processor.ts +++ b/src/functions/prompts-to-test-spec/src/services/gemini-project-processor.ts @@ -389,30 +389,6 @@ Feature: ${workitemName} (DRY RUN) return `File ${filePath} deleted successfully`; } - /** - * Get the name of the current workitem being processed - * This is a helper method to track file operations - * @returns The name of the current workitem or undefined - */ - private getCurrentWorkitemName(): string | undefined { - // This is a simple implementation that assumes the last part of the stack trace - // will contain the workitem name from the processWorkitem method - const stack = new Error().stack; - if (!stack) return undefined; - - const lines = stack.split('\n'); - for (const line of lines) { - if (line.includes('processWorkitem')) { - const match = /processWorkitem\s*\(\s*(\w+)/.exec(line); - if (match && match[1]) { - return match[1]; - } - } - } - - return undefined; - } - /** * List files in a directory in the project repository * @param dirPath Path to the directory relative to the project repository root diff --git a/src/functions/prompts-to-test-spec/src/services/project-service.ts b/src/functions/prompts-to-test-spec/src/services/project-service.ts index 6ce22f0..4408b81 100644 --- a/src/functions/prompts-to-test-spec/src/services/project-service.ts +++ b/src/functions/prompts-to-test-spec/src/services/project-service.ts @@ -65,6 +65,7 @@ export class ProjectService { // Parse INFO.md content const repoHostMatch = infoContent.match(/- \[[ x]\] Repo host: (.*)/); const repoUrlMatch = infoContent.match(/- \[[ x]\] Repo url: (.*)/); + const targetBranchMatch = infoContent.match(/- \[[ x]\] Target branch: (.*)/); const jiraComponentMatch = infoContent.match(/- \[[ x]\] Jira component: (.*)/); const project = { @@ -72,12 +73,14 @@ export class ProjectService { path: projectPath, repoHost: repoHostMatch ? repoHostMatch[1].trim() : undefined, repoUrl: repoUrlMatch ? repoUrlMatch[1].trim() : undefined, + targetBranch: targetBranchMatch ? targetBranchMatch[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(` - Target branch: ${project.targetBranch || 'Not found'}`); console.log(` - Jira component: ${project.jiraComponent || 'Not found'}`); return project; @@ -253,35 +256,72 @@ export class ProjectService { // Format the log message const timestamp = new Date().toISOString(); - let logMessage = `\n\n\n`; + let logMessage = `${timestamp} - `; switch (status) { case 'created': - logMessage += `\n`; + logMessage += `Workitem has been implemented. Created files:\n`; break; case 'updated': - logMessage += `\n`; + logMessage += `Workitem has been updated. Modified files:\n`; break; case 'deleted': - logMessage += `\n`; + logMessage += `Workitem has been deleted. Removed files:\n`; break; } // Add the list of files if (files.length > 0) { for (const file of files) { - logMessage += `\n`; + logMessage += `- ${file}\n`; } } else { - logMessage += `\n`; + logMessage += `No files were affected.\n`; } - // Append the log to the end of the file - lines.push(logMessage); + // Add PR URL if available + if (workitem.pullRequestUrl) { + logMessage += `PR: ${workitem.pullRequestUrl}\n`; + } - // Write the updated content back to the file - const updatedContent = lines.join('\n'); - fs.writeFileSync(workitem.path, updatedContent, 'utf-8'); + // Find the Log section + const logSectionIndex = lines.findIndex(line => line.trim() === '### Log'); + + if (logSectionIndex >= 0) { + // Find the next section or the end of the file + let nextSectionIndex = lines.findIndex((line, index) => + index > logSectionIndex && line.startsWith('###') + ); + + if (nextSectionIndex === -1) { + nextSectionIndex = lines.length; + } + + // Get the existing log content + const existingLogContent = lines.slice(logSectionIndex + 1, nextSectionIndex).join('\n'); + + // Insert the new log message after the "### Log" line and before any existing content + const beforeLog = lines.slice(0, logSectionIndex + 1); + const afterLog = lines.slice(nextSectionIndex); + + // Combine the parts with the new log message followed by existing log content + // Add a blank line after the log title + const updatedLines = [...beforeLog, "", logMessage, ...lines.slice(logSectionIndex + 1, nextSectionIndex), ...afterLog]; + const updatedContent = updatedLines.join('\n'); + + // Write the updated content back to the file + fs.writeFileSync(workitem.path, updatedContent, 'utf-8'); + } else { + // If no Log section is found, append it to the end of the file + console.log(`No "### Log" section found in workitem ${workitem.name}, appending to the end`); + lines.push('\n### Log'); + lines.push(''); // Add a blank line after the log title + lines.push(logMessage); + + // Write the updated content back to the file + const updatedContent = lines.join('\n'); + fs.writeFileSync(workitem.path, updatedContent, 'utf-8'); + } // Update the workitem object (no need to change any properties) return workitem; diff --git a/src/functions/prompts-to-test-spec/src/services/pull-request-service.ts b/src/functions/prompts-to-test-spec/src/services/pull-request-service.ts index 2ba5941..824ddc2 100644 --- a/src/functions/prompts-to-test-spec/src/services/pull-request-service.ts +++ b/src/functions/prompts-to-test-spec/src/services/pull-request-service.ts @@ -88,7 +88,7 @@ export class PullRequestService { title, body: description, head: branchName, - base: 'main', // Assuming the default branch is 'main' + base: project.targetBranch || 'main', // Use target branch from project info or default to 'main' }, { headers } ); @@ -140,7 +140,7 @@ export class PullRequestService { title, body: description, head: branchName, - base: 'main', // Assuming the default branch is 'main' + base: project.targetBranch || 'main', // Use target branch from project info or default to 'main' }, { headers } ); diff --git a/src/functions/prompts-to-test-spec/src/services/repository-service.ts b/src/functions/prompts-to-test-spec/src/services/repository-service.ts index e0cf620..747e97b 100644 --- a/src/functions/prompts-to-test-spec/src/services/repository-service.ts +++ b/src/functions/prompts-to-test-spec/src/services/repository-service.ts @@ -70,6 +70,12 @@ export class RepositoryService { // Clone the repository await git.clone(project.repoUrl, projectRepoDir); + // Checkout the target branch if specified + if (project.targetBranch) { + console.log(`Checking out target branch: ${project.targetBranch}`); + await this.checkoutBranch(projectRepoDir, project.targetBranch); + } + return projectRepoDir; } @@ -132,6 +138,22 @@ export class RepositoryService { return patch || "No changes detected."; } + /** + * Checkout an existing branch in a repository + * @param repoDir Path to the repository + * @param branchName Name of the branch to checkout + */ + async checkoutBranch(repoDir: string, branchName: string): Promise { + const git = simpleGit(repoDir); + try { + await git.checkout(branchName); + console.log(`Successfully checked out branch: ${branchName}`); + } catch (error) { + console.error(`Error checking out branch ${branchName}:`, error); + throw new Error(`Failed to checkout branch ${branchName}: ${error instanceof Error ? error.message : String(error)}`); + } + } + /** * Configure git with credentials * @param repoDir Path to the repository diff --git a/src/functions/prompts-to-test-spec/src/types.ts b/src/functions/prompts-to-test-spec/src/types.ts index 8f37f72..d313575 100644 --- a/src/functions/prompts-to-test-spec/src/types.ts +++ b/src/functions/prompts-to-test-spec/src/types.ts @@ -8,6 +8,7 @@ export interface Project { repoHost?: string; repoUrl?: string; jiraComponent?: string; + targetBranch?: string; } export interface Workitem { diff --git a/src/prompts/AI.md b/src/prompts/AI.md index ac4573a..446c8a1 100644 --- a/src/prompts/AI.md +++ b/src/prompts/AI.md @@ -22,6 +22,7 @@ A project info file follows the following format: - [ ] Repo host: - [ ] Repo url: +- [ ] Target branch: - [ ] Jira component: ``` @@ -41,7 +42,7 @@ A work item prompt file follows the following format: ### Log - + ``` @@ -66,5 +67,3 @@ The actual credentials are provided in the environment variables. - credential type: username/password - username variable: GITEA_USERNAME - password variable: GITEA_PASSWORD - - diff --git a/src/prompts/nitro-back/INFO.md b/src/prompts/nitro-back/INFO.md index c95eb95..b91d856 100644 --- a/src/prompts/nitro-back/INFO.md +++ b/src/prompts/nitro-back/INFO.md @@ -4,4 +4,5 @@ Nitro backend server in quarkus - [x] Repo host: https://gitea.fteamdev.valuya.be/ - [x] Repo url: https://gitea.fteamdev.valuya.be/fiscalteam/nitro-back.git +- [x] Target branch: main - [x] Jira component: nitro diff --git a/src/prompts/nitro-back/workitems/2025-06-08-test.md b/src/prompts/nitro-back/workitems/2025-06-08-test.md index 78b065c..d01b0aa 100644 --- a/src/prompts/nitro-back/workitems/2025-06-08-test.md +++ b/src/prompts/nitro-back/workitems/2025-06-08-test.md @@ -6,4 +6,4 @@ The nitro-back backend should have a /test endpoint implemented returning the js - [ ] Jira: - [ ] Implementation: -- [ ] Active +- [x] Active