This commit is contained in:
cghislai 2025-06-08 03:37:25 +02:00
parent 6172a58166
commit b207103030
9 changed files with 435 additions and 41 deletions

View File

@ -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();
});
});
});

View File

@ -389,30 +389,6 @@ Feature: ${workitemName} (DRY RUN)
return `File ${filePath} deleted successfully`; 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 * List files in a directory in the project repository
* @param dirPath Path to the directory relative to the project repository root * @param dirPath Path to the directory relative to the project repository root

View File

@ -65,6 +65,7 @@ export class ProjectService {
// 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 targetBranchMatch = infoContent.match(/- \[[ x]\] Target branch: (.*)/);
const jiraComponentMatch = infoContent.match(/- \[[ x]\] Jira component: (.*)/); const jiraComponentMatch = infoContent.match(/- \[[ x]\] Jira component: (.*)/);
const project = { const project = {
@ -72,12 +73,14 @@ export class ProjectService {
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,
targetBranch: targetBranchMatch ? targetBranchMatch[1].trim() : undefined,
jiraComponent: jiraComponentMatch ? jiraComponentMatch[1].trim() : undefined jiraComponent: jiraComponentMatch ? jiraComponentMatch[1].trim() : undefined
}; };
console.log(`ProjectService: Project info for ${projectName}:`); console.log(`ProjectService: Project info for ${projectName}:`);
console.log(` - Repository host: ${project.repoHost || 'Not found'}`); console.log(` - Repository host: ${project.repoHost || 'Not found'}`);
console.log(` - Repository URL: ${project.repoUrl || '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'}`); console.log(` - Jira component: ${project.jiraComponent || 'Not found'}`);
return project; return project;
@ -253,35 +256,72 @@ export class ProjectService {
// Format the log message // Format the log message
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
let logMessage = `\n\n<!-- Implementation Log: ${timestamp} -->\n`; let logMessage = `${timestamp} - `;
switch (status) { switch (status) {
case 'created': case 'created':
logMessage += `<!-- Workitem has been implemented. Created files: -->\n`; logMessage += `Workitem has been implemented. Created files:\n`;
break; break;
case 'updated': case 'updated':
logMessage += `<!-- Workitem has been updated. Modified files: -->\n`; logMessage += `Workitem has been updated. Modified files:\n`;
break; break;
case 'deleted': case 'deleted':
logMessage += `<!-- Workitem has been deleted. Removed files: -->\n`; logMessage += `Workitem has been deleted. Removed files:\n`;
break; break;
} }
// Add the list of files // Add the list of files
if (files.length > 0) { if (files.length > 0) {
for (const file of files) { for (const file of files) {
logMessage += `<!-- - ${file} -->\n`; logMessage += `- ${file}\n`;
} }
} else { } else {
logMessage += `<!-- No files were affected. -->\n`; logMessage += `No files were affected.\n`;
} }
// Append the log to the end of the file // Add PR URL if available
if (workitem.pullRequestUrl) {
logMessage += `PR: ${workitem.pullRequestUrl}\n`;
}
// 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); lines.push(logMessage);
// Write the updated content back to the file // Write the updated content back to the file
const updatedContent = lines.join('\n'); const updatedContent = lines.join('\n');
fs.writeFileSync(workitem.path, updatedContent, 'utf-8'); fs.writeFileSync(workitem.path, updatedContent, 'utf-8');
}
// Update the workitem object (no need to change any properties) // Update the workitem object (no need to change any properties)
return workitem; return workitem;

View File

@ -88,7 +88,7 @@ export class PullRequestService {
title, title,
body: description, body: description,
head: branchName, 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 } { headers }
); );
@ -140,7 +140,7 @@ export class PullRequestService {
title, title,
body: description, body: description,
head: branchName, 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 } { headers }
); );

View File

@ -70,6 +70,12 @@ export class RepositoryService {
// Clone the repository // Clone the repository
await git.clone(project.repoUrl, projectRepoDir); 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; return projectRepoDir;
} }
@ -132,6 +138,22 @@ export class RepositoryService {
return patch || "No changes detected."; 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<void> {
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 * Configure git with credentials
* @param repoDir Path to the repository * @param repoDir Path to the repository

View File

@ -8,6 +8,7 @@ export interface Project {
repoHost?: string; repoHost?: string;
repoUrl?: string; repoUrl?: string;
jiraComponent?: string; jiraComponent?: string;
targetBranch?: string;
} }
export interface Workitem { export interface Workitem {

View File

@ -22,6 +22,7 @@ A project info file follows the following format:
- [ ] Repo host: <repo host url, eg https://gitea.fteamdev.valuya.be/ or https://github.com/organizations/Ebitda-SRL> - [ ] Repo host: <repo host url, eg https://gitea.fteamdev.valuya.be/ or https://github.com/organizations/Ebitda-SRL>
- [ ] Repo url: <url of the project repository> - [ ] Repo url: <url of the project repository>
- [ ] Target branch: <target branch for the PR>
- [ ] Jira component: <component of the jira> - [ ] Jira component: <component of the jira>
``` ```
@ -41,7 +42,7 @@ A work item prompt file follows the following format:
### Log ### Log
<log to be filled as the workitem is processed> <log to be filled as the workitem is processed, implementation logs will be automatically added to this section>
``` ```
@ -66,5 +67,3 @@ The actual credentials are provided in the environment variables.
- credential type: username/password - credential type: username/password
- username variable: GITEA_USERNAME - username variable: GITEA_USERNAME
- password variable: GITEA_PASSWORD - password variable: GITEA_PASSWORD

View File

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

View File

@ -6,4 +6,4 @@ The nitro-back backend should have a /test endpoint implemented returning the js
- [ ] Jira: - [ ] Jira:
- [ ] Implementation: - [ ] Implementation:
- [ ] Active - [x] Active