This commit is contained in:
cghislai 2025-06-08 06:25:35 +02:00
parent 01bb760c9a
commit c160d9bc5e
33 changed files with 6847 additions and 698 deletions

View File

@ -2,7 +2,9 @@
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/src/functions/shared/coverage" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>

View File

@ -5,18 +5,19 @@ A Google Cloud Function that processes workitem prompts and generates test speci
## Overview
This function:
1. Clones the main repository containing prompts (read-only access)
2. Iterates over each project in the prompts/ directory
3. For each project:
- Clones the project repository (read-write access)
- Lets Gemini operate within the project directory
- Gemini iterates over all prompts (workitems)
- Gemini decides whether an operation is required
- Gemini implements the workitem in the target project repo or removes implementation for inactive workitems
- Gemini outputs whether the work item was skipped/created/updated/deleted
- Gemini can call functions to interact with the project repository (read, write, delete, search files, etc.)
- Tracks all file operations performed by Gemini
- Updates workitem prompts with implementation logs
- Clones the project repository (read-write access)
- Lets Gemini operate within the project directory
- Gemini iterates over all prompts (workitems)
- Gemini decides whether an operation is required
- Gemini implements the workitem in the target project repo or removes implementation for inactive workitems
- Gemini outputs whether the work item was skipped/created/updated/deleted
- Gemini can call functions to interact with the project repository (read, write, delete, search files, etc.)
- Tracks all file operations performed by Gemini
- Updates workitem prompts with implementation logs
4. Creates a pull request in the project repository with the generated test specifications and git patch
## Prerequisites
@ -24,8 +25,8 @@ This function:
- Node.js 20 or later
- Google Cloud CLI
- Google Cloud account with access to:
- Cloud Functions
- Vertex AI (for Gemini API)
- Cloud Functions
- Vertex AI (for Gemini API)
- Git credentials for the repositories
## Setup
@ -50,22 +51,27 @@ This function:
The function requires several environment variables to be set:
### Main Repository Configuration
- `MAIN_REPO_URL`: URL of the main repository containing prompts
- `MAIN_REPO_TOKEN` or `MAIN_REPO_USERNAME`/`MAIN_REPO_PASSWORD`: Credentials for the main repository
### GitHub Credentials
- `GITHUB_TOKEN` or `GITHUB_USERNAME`/`GITHUB_PASSWORD`: Credentials for GitHub repositories
### Gitea Credentials
- `GITEA_USERNAME`/`GITEA_PASSWORD`: Credentials for Gitea repositories
### Google Cloud Configuration
- `GOOGLE_CLOUD_PROJECT_ID`: Your Google Cloud project ID
- `GOOGLE_CLOUD_LOCATION`: Google Cloud region (default: us-central1)
- `GEMINI_MODEL`: Gemini model to use (default: gemini-1.5-pro)
- Note: The model must support function calling (gemini-1.5-pro and later versions support this feature)
- Note: The model must support function calling (gemini-1.5-pro and later versions support this feature)
### Function Configuration
- `DEBUG`: Set to 'true' to enable debug logging
- `USE_LOCAL_REPO`: Set to 'true' to use local repository instead of cloning
- `DRY_RUN_SKIP_GEMINI`: Set to 'true' to skip Gemini API calls (returns mock responses)
@ -73,22 +79,6 @@ The function requires several environment variables to be set:
## Local Development
There are two ways to run the function locally:
### Option 1: Direct Execution
This runs the function directly as a Node.js application:
```
npm start
```
This will execute the main processing logic directly without starting an HTTP server.
### Option 2: Functions Framework (Recommended)
This uses the Functions Framework to emulate the Cloud Functions environment locally:
1. Run the HTTP function:
```
npm run dev
@ -109,21 +99,25 @@ This uses the Functions Framework to emulate the Cloud Functions environment loc
curl http://localhost:18080
```
The Functions Framework provides a more accurate representation of how your function will behave when deployed to Google Cloud.
The Functions Framework provides a more accurate representation of how your function will behave when deployed to Google
Cloud.
## Testing
Run the tests:
```
npm test
```
Run tests in watch mode:
```
npm run test:watch
```
The test suite includes tests for:
- HTTP response formatting
- Project service functionality
- Processor service operations
@ -134,6 +128,7 @@ The test suite includes tests for:
### HTTP Trigger
Deploy the function with an HTTP trigger:
```
npm run deploy
```
@ -141,6 +136,7 @@ npm run deploy
### Event Trigger
Deploy the function with a Cloud Storage event trigger:
```
npm run deploy:event
```
@ -154,18 +150,18 @@ The function is organized into several services:
- **RepositoryService**: Handles Git operations like cloning repositories and creating branches
- **ProjectService**: Finds and processes projects and workitems
- **GeminiService**: Interacts with the Gemini API to generate test specifications
- Supports function calling to allow Gemini to interact with the project repository
- Defines file operation functions that Gemini can call (read, write, delete, list, search)
- Handles function calls and responses in a chat session
- Includes relevant files from the prompts/ directory in the prompt to Gemini
- Doesn't rely on hardcoded prompts, letting Gemini decide what to do based on workitem content
- Supports function calling to allow Gemini to interact with the project repository
- Defines file operation functions that Gemini can call (read, write, delete, list, search)
- Handles function calls and responses in a chat session
- Includes relevant files from the prompts/ directory in the prompt to Gemini
- Doesn't rely on hardcoded prompts, letting Gemini decide what to do based on workitem content
- **GeminiProjectProcessor**: Handles Gemini operations within a project directory
- Provides file access API for Gemini to use via function calling
- Implements methods for reading, writing, deleting, listing, and searching files
- Passes itself to GeminiService to handle function calls
- Tracks all file operations performed by Gemini
- Updates workitem prompts with implementation logs (created/updated/deleted files)
- Generates git patches of changes for pull request descriptions
- Provides file access API for Gemini to use via function calling
- Implements methods for reading, writing, deleting, listing, and searching files
- Passes itself to GeminiService to handle function calls
- Tracks all file operations performed by Gemini
- Updates workitem prompts with implementation logs (created/updated/deleted files)
- Generates git patches of changes for pull request descriptions
- **PullRequestService**: Creates pull requests in project repositories
- **ProcessorService**: Orchestrates the entire process

View File

@ -12,6 +12,7 @@
"@google-cloud/vertexai": "^0.5.0",
"axios": "^1.6.7",
"dotenv": "^16.4.5",
"shared-functions": "file:../shared",
"simple-git": "^3.23.0"
},
"devDependencies": {
@ -28,6 +29,25 @@
"node": ">=20"
}
},
"../shared": {
"name": "shared-functions",
"version": "1.0.0",
"dependencies": {
"axios": "^1.6.0",
"simple-git": "^3.20.0"
},
"devDependencies": {
"@types/jest": "^29.5.5",
"@types/node": "^20.8.6",
"eslint": "^8.51.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"typescript": "^5.2.2"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@ -5273,6 +5293,10 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shared-functions": {
"resolved": "../shared",
"link": true
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@ -3,7 +3,6 @@
"version": "1.0.0",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"prestart": "npm run build",
"deploy": "gcloud functions deploy promptToTestSpecHttp --gen2 --runtime=nodejs20 --source=. --trigger-http --allow-unauthenticated",
"deploy:event": "gcloud functions deploy promptToTestSpecEvent --gen2 --runtime=nodejs20 --source=. --trigger-event=google.cloud.storage.object.v1.finalized --trigger-resource=YOUR_BUCKET_NAME",
@ -20,6 +19,7 @@
"@google-cloud/vertexai": "^0.5.0",
"axios": "^1.6.7",
"dotenv": "^16.4.5",
"shared-functions": "file:../shared",
"simple-git": "^3.23.0"
},
"devDependencies": {

View File

@ -11,35 +11,6 @@ try {
// Don't throw here to allow the function to start, but it will fail when executed
}
// Check if this is being run directly (via npm start)
const isRunningDirectly = require.main === module;
if (isRunningDirectly) {
console.log('Starting prompts-to-test-spec directly...');
// Log dry run status
if (DRY_RUN_SKIP_GEMINI) {
console.log('DRY RUN: Gemini API calls will be skipped');
}
if (DRY_RUN_SKIP_COMMITS) {
console.log('DRY RUN: Commits and PRs will not be created');
}
// Run the processor
(async () => {
try {
const processor = new ProcessorService();
console.log('Processing projects...');
const results = await processor.processProjects();
const formattedResults = formatHttpResponse(results);
console.log('Processing completed successfully');
console.log('Results:', JSON.stringify(formattedResults, null, 2));
} catch (error) {
console.error('Error processing projects:', error);
process.exit(1);
}
})();
}
/**
* Format process results into a concise HTTP response
* @param results Process results from the processor service

View File

@ -20,87 +20,81 @@ describe('ProjectService', () => {
});
describe('findProjects', () => {
it('should find all projects in the prompts directory', async () => {
// Mock fs.readdirSync to return project directories
(fs.readdirSync as jest.Mock).mockReturnValueOnce([
{ name: 'project1', isDirectory: () => true },
{ name: 'project2', isDirectory: () => true },
{ name: 'not-a-project', isDirectory: () => true },
{ name: 'README.md', isDirectory: () => false }
]);
// Mock fs.existsSync to return true for prompts directory and INFO.md files
(fs.existsSync as jest.Mock).mockImplementation((path: string) => {
return path === 'prompts' || path.endsWith('project1/INFO.md') || path.endsWith('project2/INFO.md');
});
// Mock readProjectInfo
jest.spyOn(projectService, 'readProjectInfo').mockImplementation(async (projectPath, projectName) => {
return {
name: projectName,
path: projectPath,
it('should find all projects in the prompts-to-test-spec function directory', async () => {
// Mock the sharedProjectService.findProjects method
const mockProjects = [
{
name: 'project1',
path: 'prompts/prompts-to-test-spec/project1',
repoHost: 'https://github.com',
repoUrl: `https://github.com/org/${projectName}.git`,
jiraComponent: projectName
};
});
repoUrl: 'https://github.com/org/project1.git',
jiraComponent: 'project1'
},
{
name: 'project2',
path: 'prompts/prompts-to-test-spec/project2',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/project2.git',
jiraComponent: 'project2'
}
];
// Access the private sharedProjectService and mock its findProjects method
const sharedProjectService = (projectService as any).sharedProjectService;
jest.spyOn(sharedProjectService, 'findProjects').mockResolvedValue(mockProjects);
const projects = await projectService.findProjects('prompts');
expect(projects).toHaveLength(2);
expect(projects[0].name).toBe('project1');
expect(projects[1].name).toBe('project2');
expect(fs.readdirSync).toHaveBeenCalledWith('prompts', { withFileTypes: true });
expect(fs.existsSync).toHaveBeenCalledWith('prompts/project1/INFO.md');
expect(fs.existsSync).toHaveBeenCalledWith('prompts/project2/INFO.md');
expect(fs.existsSync).toHaveBeenCalledWith('prompts/not-a-project/INFO.md');
expect(sharedProjectService.findProjects).toHaveBeenCalledWith('prompts', 'prompts-to-test-spec');
});
});
describe('readProjectInfo', () => {
it('should read project information from INFO.md', async () => {
const infoContent = `# Project Name
- [x] Repo host: https://github.com
- [x] Repo url: https://github.com/org/project.git
- [x] Jira component: project-component
`;
// Mock fs.readFileSync to return INFO.md content
(fs.readFileSync as jest.Mock).mockReturnValueOnce(infoContent);
const project = await projectService.readProjectInfo('path/to/project', 'project');
expect(project).toEqual({
// Mock the expected project object
const expectedProject = {
name: 'project',
path: 'path/to/project',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/project.git',
targetBranch: 'main',
aiGuidelines: 'docs/AI_GUIDELINES.md',
jiraComponent: 'project-component'
});
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);
// Access the private sharedProjectService and mock its readProjectInfo method
const sharedProjectService = (projectService as any).sharedProjectService;
jest.spyOn(sharedProjectService, 'readProjectInfo').mockResolvedValue(expectedProject);
const project = await projectService.readProjectInfo('path/to/project', 'project');
expect(project).toEqual({
expect(project).toEqual(expectedProject);
expect(sharedProjectService.readProjectInfo).toHaveBeenCalledWith('path/to/project', 'project');
});
it('should handle project information that does not follow the expected format', async () => {
// Mock the expected project object with undefined values
const expectedProject = {
name: 'project',
path: 'path/to/project',
repoHost: undefined,
repoUrl: undefined,
targetBranch: undefined,
aiGuidelines: undefined,
jiraComponent: undefined
});
expect(fs.readFileSync).toHaveBeenCalledWith('path/to/project/INFO.md', 'utf-8');
};
// Access the private sharedProjectService and mock its readProjectInfo method
const sharedProjectService = (projectService as any).sharedProjectService;
jest.spyOn(sharedProjectService, 'readProjectInfo').mockResolvedValue(expectedProject);
const project = await projectService.readProjectInfo('path/to/project', 'project');
expect(project).toEqual(expectedProject);
expect(sharedProjectService.readProjectInfo).toHaveBeenCalledWith('path/to/project', 'project');
});
});

View File

@ -3,327 +3,266 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import { Project, Workitem } from '../types';
import { ProjectService as SharedProjectService, Project, Workitem } from 'shared-functions';
export class ProjectService {
/**
* Find all projects in the prompts directory
* @param promptsDir Path to the prompts directory
* @returns Array of projects
*/
async findProjects(promptsDir: string): Promise<Project[]> {
const projects: Project[] = [];
private sharedProjectService: SharedProjectService;
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;
constructor() {
this.sharedProjectService = new SharedProjectService();
}
// Get all directories in the prompts directory
const entries = fs.readdirSync(promptsDir, { withFileTypes: true });
const projectDirs = entries.filter(entry => entry.isDirectory());
console.log(`ProjectService: Found ${projectDirs.length} potential project directories`);
for (const dir of projectDirs) {
const projectPath = path.join(promptsDir, dir.name);
const infoPath = path.join(projectPath, 'INFO.md');
console.log(`ProjectService: Checking directory: ${dir.name}`);
// Skip directories without INFO.md
if (!fs.existsSync(infoPath)) {
console.log(`ProjectService: Skipping ${dir.name} - no INFO.md file found`);
continue;
}
console.log(`ProjectService: Found INFO.md in ${dir.name}, reading project info`);
// Read project info
const project = await this.readProjectInfo(projectPath, dir.name);
projects.push(project);
console.log(`ProjectService: Added project: ${project.name}`);
/**
* Find all projects in the prompts directory
* @param promptsDir Path to the prompts directory
* @returns Array of projects
*/
async findProjects(promptsDir: string): Promise<Project[]> {
return this.sharedProjectService.findProjects(promptsDir, 'prompts-to-test-spec');
}
return projects;
}
/**
* Find all workitems in a project
* @param projectPath Path to the project directory
* @returns Array of workitems
*/
async findWorkitems(projectPath: string): Promise<Workitem[]> {
const workitems: Workitem[] = [];
const workitemsDir = path.join(projectPath, 'workitems');
/**
* Read project information from INFO.md
* @param projectPath Path to the project directory
* @param projectName Name of the project
* @returns Project information
*/
async readProjectInfo(projectPath: string, projectName: string): Promise<Project> {
const infoPath = path.join(projectPath, 'INFO.md');
console.log(`ProjectService: Reading project info from ${infoPath}`);
const infoContent = fs.readFileSync(infoPath, 'utf-8');
// Skip if workitems directory doesn't exist
if (!fs.existsSync(workitemsDir)) {
return workitems;
}
// 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: (.*)/);
// Get all markdown files in the workitems directory
const files = fs.readdirSync(workitemsDir)
.filter(file => file.endsWith('.md'));
const project = {
name: projectName,
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
};
for (const file of files) {
const workitemPath = path.join(workitemsDir, file);
const workitem = await this.readWorkitemInfo(workitemPath, file);
workitems.push(workitem);
}
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;
}
/**
* Find all workitems in a project
* @param projectPath Path to the project directory
* @returns Array of workitems
*/
async findWorkitems(projectPath: string): Promise<Workitem[]> {
const workitems: Workitem[] = [];
const workitemsDir = path.join(projectPath, 'workitems');
// Skip if workitems directory doesn't exist
if (!fs.existsSync(workitemsDir)) {
return workitems;
return workitems;
}
// Get all markdown files in the workitems directory
const files = fs.readdirSync(workitemsDir)
.filter(file => file.endsWith('.md'));
/**
* Read workitem information from a markdown file
* @param workitemPath Path to the workitem file
* @param fileName Name of the workitem file
* @returns Workitem information
*/
async readWorkitemInfo(workitemPath: string, fileName: string): Promise<Workitem> {
const content = fs.readFileSync(workitemPath, 'utf-8');
for (const file of files) {
const workitemPath = path.join(workitemsDir, file);
const workitem = await this.readWorkitemInfo(workitemPath, file);
workitems.push(workitem);
}
// Parse workitem content
const titleMatch = content.match(/## (.*)/);
const jiraMatch = content.match(/- \[[ x]\] Jira: (.*)/);
const implementationMatch = content.match(/- \[[ x]\] Implementation: (.*)/);
const pullRequestUrlMatch = content.match(/- \[[ x]\] Pull Request: (.*)/);
const activeMatch = content.match(/- \[([x ])\] Active/);
return workitems;
}
// Extract description (everything between title and first metadata line)
let description = '';
const lines = content.split('\n');
let titleIndex = -1;
let metadataIndex = -1;
/**
* Read workitem information from a markdown file
* @param workitemPath Path to the workitem file
* @param fileName Name of the workitem file
* @returns Workitem information
*/
async readWorkitemInfo(workitemPath: string, fileName: string): Promise<Workitem> {
const content = fs.readFileSync(workitemPath, 'utf-8');
// Parse workitem content
const titleMatch = content.match(/## (.*)/);
const jiraMatch = content.match(/- \[[ x]\] Jira: (.*)/);
const implementationMatch = content.match(/- \[[ x]\] Implementation: (.*)/);
const pullRequestUrlMatch = content.match(/- \[[ x]\] Pull Request: (.*)/);
const activeMatch = content.match(/- \[([x ])\] Active/);
// Extract description (everything between title and first metadata line)
let description = '';
const lines = content.split('\n');
let titleIndex = -1;
let metadataIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (titleIndex === -1 && lines[i].startsWith('## ')) {
titleIndex = i;
} else if (titleIndex !== -1 && metadataIndex === -1 && lines[i].startsWith('- [')) {
metadataIndex = i;
}
}
if (titleIndex !== -1 && metadataIndex !== -1) {
description = lines.slice(titleIndex + 1, metadataIndex).join('\n').trim();
}
// Determine if workitem is active
// If the Active checkbox is missing, assume it's active
const isActive = activeMatch ? activeMatch[1] === 'x' : true;
return {
name: fileName.replace('.md', ''),
path: workitemPath,
title: titleMatch ? titleMatch[1].trim() : fileName,
description,
jiraReference: jiraMatch ? jiraMatch[1].trim() : undefined,
implementation: implementationMatch ? implementationMatch[1].trim() : undefined,
pullRequestUrl: pullRequestUrlMatch ? pullRequestUrlMatch[1].trim() : undefined,
isActive
};
}
/**
* Read AI guidelines for a project
* @param projectPath Path to the project directory
* @returns AI guidelines content
*/
async readProjectGuidelines(projectPath: string): Promise<string> {
const aiPath = path.join(projectPath, 'AI.md');
if (!fs.existsSync(aiPath)) {
return '';
}
return fs.readFileSync(aiPath, 'utf-8');
}
/**
* Update workitem file with pull request URL
* @param workitem Workitem to update
* @param pullRequestUrl Pull request URL to add
* @returns Updated workitem
*/
async updateWorkitemWithPullRequestUrl(workitem: Workitem, pullRequestUrl: string): Promise<Workitem> {
if (!fs.existsSync(workitem.path)) {
throw new Error(`Workitem file not found: ${workitem.path}`);
}
// Read the current content
let content = fs.readFileSync(workitem.path, 'utf-8');
const lines = content.split('\n');
// Check if Pull Request line already exists
const pullRequestLineIndex = lines.findIndex(line => line.match(/- \[[ x]\] Pull Request:/));
if (pullRequestLineIndex >= 0) {
// Update existing line
lines[pullRequestLineIndex] = `- [x] Pull Request: ${pullRequestUrl}`;
} else {
// Find where to insert the new line (before Active line or at the end of metadata)
const activeLineIndex = lines.findIndex(line => line.match(/- \[[ x]\] Active/));
if (activeLineIndex >= 0) {
// Insert before Active line
lines.splice(activeLineIndex, 0, `- [x] Pull Request: ${pullRequestUrl}`);
} else {
// Find the last metadata line and insert after it
let lastMetadataIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].match(/- \[[ x]\]/)) {
lastMetadataIndex = i;
}
if (titleIndex === -1 && lines[i].startsWith('## ')) {
titleIndex = i;
} else if (titleIndex !== -1 && metadataIndex === -1 && lines[i].startsWith('- [')) {
metadataIndex = i;
}
}
if (lastMetadataIndex >= 0) {
// Insert after the last metadata line
lines.splice(lastMetadataIndex + 1, 0, `- [x] Pull Request: ${pullRequestUrl}`);
if (titleIndex !== -1 && metadataIndex !== -1) {
description = lines.slice(titleIndex + 1, metadataIndex).join('\n').trim();
}
// Determine if workitem is active
// If the Active checkbox is missing, assume it's active
const isActive = activeMatch ? activeMatch[1] === 'x' : true;
return {
name: fileName.replace('.md', ''),
path: workitemPath,
title: titleMatch ? titleMatch[1].trim() : fileName,
description,
jiraReference: jiraMatch ? jiraMatch[1].trim() : undefined,
implementation: implementationMatch ? implementationMatch[1].trim() : undefined,
pullRequestUrl: pullRequestUrlMatch ? pullRequestUrlMatch[1].trim() : undefined,
isActive
};
}
/**
* Update workitem file with pull request URL
* @param workitem Workitem to update
* @param pullRequestUrl Pull request URL to add
* @returns Updated workitem
*/
async updateWorkitemWithPullRequestUrl(workitem: Workitem, pullRequestUrl: string): Promise<Workitem> {
if (!fs.existsSync(workitem.path)) {
throw new Error(`Workitem file not found: ${workitem.path}`);
}
// Read the current content
let content = fs.readFileSync(workitem.path, 'utf-8');
const lines = content.split('\n');
// Check if Pull Request line already exists
const pullRequestLineIndex = lines.findIndex(line => line.match(/- \[[ x]\] Pull Request:/));
if (pullRequestLineIndex >= 0) {
// Update existing line
lines[pullRequestLineIndex] = `- [x] Pull Request: ${pullRequestUrl}`;
} else {
// No metadata found, append to the end
lines.push(`- [x] Pull Request: ${pullRequestUrl}`);
// Find where to insert the new line (before Active line or at the end of metadata)
const activeLineIndex = lines.findIndex(line => line.match(/- \[[ x]\] Active/));
if (activeLineIndex >= 0) {
// Insert before Active line
lines.splice(activeLineIndex, 0, `- [x] Pull Request: ${pullRequestUrl}`);
} else {
// Find the last metadata line and insert after it
let lastMetadataIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].match(/- \[[ x]\]/)) {
lastMetadataIndex = i;
}
}
if (lastMetadataIndex >= 0) {
// Insert after the last metadata line
lines.splice(lastMetadataIndex + 1, 0, `- [x] Pull Request: ${pullRequestUrl}`);
} else {
// No metadata found, append to the end
lines.push(`- [x] Pull Request: ${pullRequestUrl}`);
}
}
}
}
// Write the updated content back to the file
const updatedContent = lines.join('\n');
fs.writeFileSync(workitem.path, updatedContent, 'utf-8');
// Update the workitem object
const updatedWorkitem = {...workitem, pullRequestUrl};
return updatedWorkitem;
}
// Write the updated content back to the file
const updatedContent = lines.join('\n');
fs.writeFileSync(workitem.path, updatedContent, 'utf-8');
// Update the workitem object
const updatedWorkitem = { ...workitem, pullRequestUrl };
return updatedWorkitem;
}
/**
* Update workitem file with implementation log
* @param workitem Workitem to update
* @param status Status of the workitem (created, updated, deleted)
* @param files Array of files that were created, updated, or deleted
* @returns Updated workitem
*/
async updateWorkitemWithImplementationLog(
workitem: Workitem,
status: 'created' | 'updated' | 'deleted',
files: string[]
): Promise<Workitem> {
if (!fs.existsSync(workitem.path)) {
throw new Error(`Workitem file not found: ${workitem.path}`);
/**
* Read project information from INFO.md
* @param projectPath Path to the project directory
* @param projectName Name of the project
* @returns Project information
*/
async readProjectInfo(projectPath: string, projectName: string): Promise<Project> {
return this.sharedProjectService.readProjectInfo(projectPath, projectName);
}
// Read the current content
let content = fs.readFileSync(workitem.path, 'utf-8');
const lines = content.split('\n');
// Format the log message
const timestamp = new Date().toISOString();
let logMessage = `${timestamp} - `;
switch (status) {
case 'created':
logMessage += `Workitem has been implemented. Created files:\n`;
break;
case 'updated':
logMessage += `Workitem has been updated. Modified files:\n`;
break;
case 'deleted':
logMessage += `Workitem has been deleted. Removed files:\n`;
break;
/**
* Read AI guidelines for a project
* @param projectPath Path to the project directory
* @returns AI guidelines content
*/
async readProjectGuidelines(projectPath: string): Promise<string> {
return this.sharedProjectService.readProjectGuidelines(projectPath);
}
// Add the list of files
if (files.length > 0) {
for (const file of files) {
logMessage += `- ${file}\n`;
}
} else {
logMessage += `No files were affected.\n`;
/**
* Update workitem file with implementation log
* @param workitem Workitem to update
* @param status Status of the workitem (created, updated, deleted)
* @param files Array of files that were created, updated, or deleted
* @returns Updated workitem
*/
async updateWorkitemWithImplementationLog(
workitem: Workitem,
status: 'created' | 'updated' | 'deleted',
files: string[]
): Promise<Workitem> {
if (!fs.existsSync(workitem.path)) {
throw new Error(`Workitem file not found: ${workitem.path}`);
}
// Read the current content
let content = fs.readFileSync(workitem.path, 'utf-8');
const lines = content.split('\n');
// Format the log message
const timestamp = new Date().toISOString();
let logMessage = `${timestamp} - `;
switch (status) {
case 'created':
logMessage += `Workitem has been implemented. Created files:\n`;
break;
case 'updated':
logMessage += `Workitem has been updated. Modified files:\n`;
break;
case 'deleted':
logMessage += `Workitem has been deleted. Removed files:\n`;
break;
}
// Add the list of files
if (files.length > 0) {
for (const file of files) {
logMessage += `- ${file}\n`;
}
} else {
logMessage += `No files were affected.\n`;
}
// 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);
// 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;
}
// 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);
// 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;
}
}

View File

@ -1,15 +1,15 @@
/**
* Service for handling pull request operations
*/
import axios from 'axios';
import * as path from 'path';
import {Project, RepoCredentials, Workitem} from '../types';
import {PullRequestService as SharedPullRequestService, Project, RepoCredentials, Workitem} from 'shared-functions';
import {GeminiService} from './gemini-service';
export class PullRequestService {
private sharedPullRequestService: SharedPullRequestService;
private geminiService: GeminiService;
constructor() {
this.sharedPullRequestService = new SharedPullRequestService();
this.geminiService = new GeminiService();
}
@ -19,6 +19,7 @@ export class PullRequestService {
* @param branchName Name of the branch with changes
* @param processedWorkitems List of processed workitems
* @param credentials Repository credentials
* @param gitPatch Optional git patch to include in the description
* @returns URL of the created pull request
*/
async createPullRequest(
@ -34,125 +35,12 @@ export class PullRequestService {
credentials: RepoCredentials,
gitPatch?: string
): Promise<string> {
if (!project.repoHost || !project.repoUrl) {
throw new Error(`Repository information not found for project ${project.name}`);
}
// Generate PR title and description
const title = `Update workitems: ${new Date().toISOString().split('T')[0]}`;
const description = await this.generatePullRequestDescription(processedWorkitems, gitPatch);
// Determine the repository host type and create PR accordingly
if (project.repoHost.includes('github.com')) {
return this.createGithubPullRequest(project, branchName, title, description, credentials);
} else if (project.repoHost.includes('gitea')) {
return this.createGiteaPullRequest(project, branchName, title, description, credentials);
} else {
throw new Error(`Unsupported repository host: ${project.repoHost}`);
}
}
/**
* Create a pull request on GitHub
* @param project Project information
* @param branchName Name of the branch with changes
* @param title Pull request title
* @param description Pull request description
* @param credentials Repository credentials
* @returns URL of the created pull request
*/
private async createGithubPullRequest(
project: Project,
branchName: string,
title: string,
description: string,
credentials: RepoCredentials
): Promise<string> {
// Extract owner and repo from the repository URL
const repoUrlParts = project.repoUrl!.split('/');
const repo = path.basename(repoUrlParts[repoUrlParts.length - 1], '.git');
const owner = repoUrlParts[repoUrlParts.length - 2];
// Create the pull request
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls`;
const headers: Record<string, string> = {
'Accept': 'application/vnd.github.v3+json',
};
if (credentials.type === 'token' && credentials.token) {
headers['Authorization'] = `token ${credentials.token}`;
} else if (credentials.type === 'username-password' && credentials.username && credentials.password) {
const auth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
headers['Authorization'] = `Basic ${auth}`;
} else {
throw new Error('Invalid credentials for GitHub');
}
const response = await axios.post(
apiUrl,
{
title,
body: description,
head: branchName,
base: project.targetBranch || 'main', // Use target branch from project info or default to 'main'
},
{headers}
);
return response.data.html_url;
}
/**
* Create a pull request on Gitea
* @param project Project information
* @param branchName Name of the branch with changes
* @param title Pull request title
* @param description Pull request description
* @param credentials Repository credentials
* @returns URL of the created pull request
*/
private async createGiteaPullRequest(
project: Project,
branchName: string,
title: string,
description: string,
credentials: RepoCredentials
): Promise<string> {
// Extract owner and repo from the repository URL
const repoUrlParts = project.repoUrl!.split('/');
const repo = path.basename(repoUrlParts[repoUrlParts.length - 1], '.git');
const owner = repoUrlParts[repoUrlParts.length - 2];
// Create the pull request
const apiUrl = `${project.repoHost}/api/v1/repos/${owner}/${repo}/pulls`;
const headers: Record<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
if (credentials.type === 'token' && credentials.token) {
headers['Authorization'] = `token ${credentials.token}`;
} else if (credentials.type === 'username-password' && credentials.username && credentials.password) {
const auth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
headers['Authorization'] = `Basic ${auth}`;
} else {
throw new Error('Invalid credentials for Gitea');
}
const response = await axios.post(
apiUrl,
{
title,
body: description,
head: branchName,
base: project.targetBranch || 'main', // Use target branch from project info or default to 'main'
},
{headers}
);
return response.data.html_url;
// Use the shared implementation to create the pull request
return this.sharedPullRequestService.createPullRequest(project, branchName, credentials, title, description);
}
/**

View File

@ -1,180 +1,82 @@
/**
* Service for handling repository operations
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { simpleGit, SimpleGit } from 'simple-git';
import { Project, RepoCredentials } from '../types';
import {RepositoryService as SharedRepositoryService, Project, RepoCredentials} from 'shared-functions';
export class RepositoryService {
private baseDir: string;
private sharedRepositoryService: SharedRepositoryService;
constructor(baseDir?: string) {
this.baseDir = baseDir || path.join(os.tmpdir(), 'prompts-to-test-spec');
// Ensure base directory exists
if (!fs.existsSync(this.baseDir)) {
fs.mkdirSync(this.baseDir, { recursive: true });
}
}
/**
* Clone the main repository containing prompts
* @param repoUrl URL of the repository
* @param credentials Optional credentials for private repositories
* @returns Path to the cloned repository
*/
async cloneMainRepository(repoUrl: string, credentials?: RepoCredentials): Promise<string> {
const repoDir = path.join(this.baseDir, 'main-repo');
// Clean up existing directory if it exists
if (fs.existsSync(repoDir)) {
fs.rmSync(repoDir, { recursive: true, force: true });
constructor(baseDir?: string) {
// Use a different base directory for prompts-to-test-spec
const repoBaseDir = baseDir || path.join(os.tmpdir(), 'prompts-to-test-spec');
this.sharedRepositoryService = new SharedRepositoryService(repoBaseDir);
}
fs.mkdirSync(repoDir, { recursive: true });
// Configure git with credentials if provided
const git = this.configureGit(repoDir, credentials);
// Clone the repository
await git.clone(repoUrl, repoDir);
return repoDir;
}
/**
* Clone a project repository
* @param project Project information
* @param credentials Optional credentials for private repositories
* @returns Path to the cloned repository
*/
async cloneProjectRepository(project: Project, credentials?: RepoCredentials): Promise<string> {
if (!project.repoUrl) {
throw new Error(`Repository URL not found for project ${project.name}`);
/**
* Clone the main repository containing prompts
* @param repoUrl URL of the repository
* @param credentials Optional credentials for private repositories
* @returns Path to the cloned repository
*/
async cloneMainRepository(repoUrl: string, credentials?: RepoCredentials): Promise<string> {
return this.sharedRepositoryService.cloneMainRepository(repoUrl, credentials);
}
const projectRepoDir = path.join(this.baseDir, `project-${project.name}`);
// Clean up existing directory if it exists
if (fs.existsSync(projectRepoDir)) {
fs.rmSync(projectRepoDir, { recursive: true, force: true });
/**
* Clone a project repository
* @param project Project information
* @param credentials Optional credentials for private repositories
* @returns Path to the cloned repository
*/
async cloneProjectRepository(project: Project, credentials?: RepoCredentials): Promise<string> {
return this.sharedRepositoryService.cloneProjectRepository(project, credentials);
}
fs.mkdirSync(projectRepoDir, { recursive: true });
// Configure git with credentials if provided
const git = this.configureGit(projectRepoDir, credentials);
// 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);
/**
* Create a new branch in a repository
* @param repoDir Path to the repository
* @param branchName Name of the branch to create
*/
async createBranch(repoDir: string, branchName: string): Promise<void> {
return this.sharedRepositoryService.createBranch(repoDir, branchName);
}
return projectRepoDir;
}
/**
* Create a new branch in a repository
* @param repoDir Path to the repository
* @param branchName Name of the branch to create
*/
async createBranch(repoDir: string, branchName: string): Promise<void> {
const git = simpleGit(repoDir);
await git.checkoutLocalBranch(branchName);
}
/**
* Commit changes to a repository
* @param repoDir Path to the repository
* @param message Commit message
*/
async commitChanges(repoDir: string, message: string): Promise<void> {
const git = simpleGit(repoDir);
await git.add('.');
await git.commit(message);
}
/**
* Push changes to a repository
* @param repoDir Path to the repository
* @param branchName Name of the branch to push
* @param credentials Optional credentials for private repositories
*/
async pushChanges(repoDir: string, branchName: string, credentials?: RepoCredentials): Promise<void> {
const git = this.configureGit(repoDir, credentials);
await git.push('origin', branchName, ['--set-upstream']);
}
/**
* Generate a git patch of the changes in a repository
* @param repoDir Path to the repository
* @returns Git patch as a string
*/
async generateGitPatch(repoDir: string): Promise<string> {
const git = simpleGit(repoDir);
// Check if there are any changes
const status = await git.status();
if (status.files.length === 0) {
return "No changes detected.";
/**
* Commit changes to a repository
* @param repoDir Path to the repository
* @param message Commit message
*/
async commitChanges(repoDir: string, message: string): Promise<void> {
return this.sharedRepositoryService.commitChanges(repoDir, message);
}
// Generate a diff of all changes (staged and unstaged)
const diff = await git.diff(['--staged', '--no-color']);
const untrackedDiff = await git.diff(['--no-index', '/dev/null', ...status.not_added.map(file => path.join(repoDir, file))]).catch(() => '');
// Combine the diffs
let patch = diff;
if (untrackedDiff) {
patch += '\n\n' + untrackedDiff;
/**
* Push changes to a repository
* @param repoDir Path to the repository
* @param branchName Name of the branch to push
* @param credentials Optional credentials for private repositories
*/
async pushChanges(repoDir: string, branchName: string, credentials?: RepoCredentials): Promise<void> {
return this.sharedRepositoryService.pushChanges(repoDir, branchName, credentials);
}
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
* @param repoDir Path to the repository
* @param credentials Credentials for authentication
* @returns Configured SimpleGit instance
*/
private configureGit(repoDir: string, credentials?: RepoCredentials): SimpleGit {
const git = simpleGit(repoDir);
if (credentials) {
if (credentials.type === 'username-password' && credentials.username && credentials.password) {
// For HTTPS URLs with username/password
const credentialHelper = `!f() { echo "username=${credentials.username}"; echo "password=${credentials.password}"; }; f`;
git.addConfig('credential.helper', credentialHelper, false, 'global');
} else if (credentials.type === 'token' && credentials.token) {
// For HTTPS URLs with token
const credentialHelper = `!f() { echo "password=${credentials.token}"; }; f`;
git.addConfig('credential.helper', credentialHelper, false, 'global');
}
/**
* Generate a git patch of the changes in a repository
* @param repoDir Path to the repository
* @returns Git patch as a string
*/
async generateGitPatch(repoDir: string): Promise<string> {
return this.sharedRepositoryService.generateGitPatch(repoDir);
}
return git;
}
/**
* 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> {
return this.sharedRepositoryService.checkoutBranch(repoDir, branchName);
}
}

View File

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

4
src/functions/shared/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.env
coverage/

View File

@ -0,0 +1,123 @@
# Shared Functions Utilities
This package provides shared utilities for functions in the project. It contains common code that can be reused across multiple functions.
## Installation
To use these utilities in your function, add the following to your `package.json`:
```json
{
"dependencies": {
"shared-functions": "file:../shared"
}
}
```
Then run `npm install` to install the dependency.
## Usage
### Import the utilities
```typescript
// Import types
import { Project, Workitem, RepoCredentials } from 'shared-functions';
// Import services
import { ProjectService, RepositoryService, PullRequestService } from 'shared-functions';
```
### ProjectService
The `ProjectService` provides utilities for working with projects and workitems:
```typescript
const projectService = new ProjectService();
// Find all projects for a specific function
const projects = await projectService.findProjects('path/to/prompts', 'function-name');
// Read project information
const project = await projectService.readProjectInfo('path/to/project', 'project-name');
// Find all workitems in a project
const workitems = await projectService.findWorkitems('path/to/project');
// Read workitem information
const workitem = await projectService.readWorkitemInfo('path/to/workitem.md', 'workitem.md');
// Read project guidelines
const guidelines = await projectService.readProjectGuidelines('path/to/project');
// Update workitem with pull request URL
const updatedWorkitem = await projectService.updateWorkitemWithPullRequestUrl(workitem, 'https://github.com/org/repo/pull/123');
```
### RepositoryService
The `RepositoryService` provides utilities for working with Git repositories:
```typescript
const repositoryService = new RepositoryService();
// Clone the main repository
const mainRepoPath = await repositoryService.cloneMainRepository('https://github.com/org/repo.git');
// Clone a project repository
const projectRepoPath = await repositoryService.cloneProjectRepository(project);
// Create a new branch
await repositoryService.createBranch('path/to/repo', 'feature/branch');
// Commit changes
await repositoryService.commitChanges('path/to/repo', 'Commit message');
// Push changes
await repositoryService.pushChanges('path/to/repo', 'feature/branch');
// Generate a git patch
const patch = await repositoryService.generateGitPatch('path/to/repo');
// Checkout a branch
await repositoryService.checkoutBranch('path/to/repo', 'main');
```
### PullRequestService
The `PullRequestService` provides utilities for creating pull requests:
```typescript
const pullRequestService = new PullRequestService();
// Create a pull request
const pullRequestUrl1 = await pullRequestService.createPullRequest(
project,
'feature/branch',
processedWorkitems,
credentials
);
// Create a pull request with a custom description
const pullRequestUrl2 = await pullRequestService.createPullRequest(
project,
'feature/branch',
processedWorkitems,
credentials,
'Custom pull request description'
);
```
## Development
### Building
```bash
npm run build
```
### Testing
```bash
npm test
```

View File

@ -0,0 +1,12 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
collectCoverage: true,
coverageDirectory: 'coverage',
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/__tests__/**'
]
};

5052
src/functions/shared/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
{
"name": "shared-functions",
"version": "1.0.0",
"description": "Shared utilities for functions",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "jest",
"lint": "eslint src/**/*.ts"
},
"dependencies": {
"axios": "^1.6.0",
"simple-git": "^3.20.0"
},
"devDependencies": {
"@types/jest": "^29.5.5",
"@types/node": "^20.8.6",
"eslint": "^8.51.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"typescript": "^5.2.2"
},
"engines": {
"node": ">=20"
},
"files": [
"dist"
]
}

View File

@ -0,0 +1,11 @@
/**
* Shared utilities for functions
*/
// Export types
export * from './types';
// Export services
export { ProjectService } from './services/project-service';
export { RepositoryService } from './services/repository-service';
export { PullRequestService } from './services/pull-request-service';

View File

@ -0,0 +1,174 @@
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', () => {
let projectService: ProjectService;
beforeEach(() => {
projectService = new ProjectService();
// Reset all mocks
jest.resetAllMocks();
// Mock path.join to return predictable paths
(path.join as jest.Mock).mockImplementation((...args) => args.join('/'));
});
describe('findProjects', () => {
it('should find all projects in the function directory', async () => {
// Mock fs.existsSync to return true for prompts directory and function directory
(fs.existsSync as jest.Mock).mockImplementation((path: string) => {
return path === 'prompts' || path === 'prompts/function1';
});
// Mock fs.readdirSync to return project directories for function1
(fs.readdirSync as jest.Mock).mockReturnValueOnce([
{ name: 'project1', isDirectory: () => true },
{ name: 'not-a-project', isDirectory: () => true },
{ name: 'project2', isDirectory: () => true },
]);
// Mock fs.existsSync to return true for INFO.md files
(fs.existsSync as jest.Mock).mockImplementation((path: string) => {
return path === 'prompts' ||
path === 'prompts/function1' ||
path.endsWith('function1/project1/INFO.md') ||
path.endsWith('function1/project2/INFO.md');
});
// Mock readProjectInfo
jest.spyOn(projectService, 'readProjectInfo').mockImplementation(async (projectPath, projectName) => {
return {
name: projectName,
path: projectPath,
repoHost: 'https://github.com',
repoUrl: `https://github.com/org/${projectName}.git`,
jiraComponent: projectName
};
});
const projects = await projectService.findProjects('prompts', 'function1');
expect(projects).toHaveLength(2);
expect(projects[0].name).toBe('project1');
expect(projects[1].name).toBe('project2');
expect(fs.readdirSync).toHaveBeenCalledWith('prompts/function1', { withFileTypes: true });
expect(fs.existsSync).toHaveBeenCalledWith('prompts/function1/project1/INFO.md');
expect(fs.existsSync).toHaveBeenCalledWith('prompts/function1/not-a-project/INFO.md');
expect(fs.existsSync).toHaveBeenCalledWith('prompts/function1/project2/INFO.md');
});
it('should return empty array if prompts directory does not exist', async () => {
// Mock fs.existsSync to return false for prompts directory
(fs.existsSync as jest.Mock).mockReturnValueOnce(false);
const projects = await projectService.findProjects('prompts', 'function1');
expect(projects).toHaveLength(0);
expect(fs.existsSync).toHaveBeenCalledWith('prompts');
expect(fs.readdirSync).not.toHaveBeenCalled();
});
it('should return empty array if function directory does not exist', async () => {
// Mock fs.existsSync to return true for prompts directory but false for function directory
(fs.existsSync as jest.Mock).mockImplementation((path: string) => {
return path === 'prompts';
});
const projects = await projectService.findProjects('prompts', 'function1');
expect(projects).toHaveLength(0);
expect(fs.existsSync).toHaveBeenCalledWith('prompts');
expect(fs.existsSync).toHaveBeenCalledWith('prompts/function1');
expect(fs.readdirSync).not.toHaveBeenCalled();
});
});
describe('readProjectInfo', () => {
it('should read project information from INFO.md', async () => {
const infoContent = `# Project Name
- [x] Repo host: https://github.com
- [x] Repo url: https://github.com/org/project.git
- [x] Target branch: main
- [x] AI guidelines: docs/AI_GUIDELINES.md
- [x] Jira component: project-component
`;
// Mock fs.readFileSync to return INFO.md content
(fs.readFileSync as jest.Mock).mockReturnValueOnce(infoContent);
const project = await projectService.readProjectInfo('path/to/project', 'project');
expect(project).toEqual({
name: 'project',
path: 'path/to/project',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/project.git',
targetBranch: 'main',
aiGuidelines: 'docs/AI_GUIDELINES.md',
jiraComponent: 'project-component'
});
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,
targetBranch: undefined,
aiGuidelines: undefined,
jiraComponent: undefined
});
expect(fs.readFileSync).toHaveBeenCalledWith('path/to/project/INFO.md', 'utf-8');
});
});
describe('readProjectGuidelines', () => {
it('should read AI guidelines for a project', async () => {
const guidelinesContent = '## Guidelines\n\nThese are the guidelines.';
// Mock fs.existsSync to return true for AI.md
(fs.existsSync as jest.Mock).mockReturnValueOnce(true);
// Mock fs.readFileSync to return guidelines content
(fs.readFileSync as jest.Mock).mockReturnValueOnce(guidelinesContent);
const guidelines = await projectService.readProjectGuidelines('path/to/project');
expect(guidelines).toBe(guidelinesContent);
expect(fs.existsSync).toHaveBeenCalledWith('path/to/project/AI.md');
expect(fs.readFileSync).toHaveBeenCalledWith('path/to/project/AI.md', 'utf-8');
});
it('should return empty string if AI.md does not exist', async () => {
// Mock fs.existsSync to return false for AI.md
(fs.existsSync as jest.Mock).mockReturnValueOnce(false);
const guidelines = await projectService.readProjectGuidelines('path/to/project');
expect(guidelines).toBe('');
expect(fs.existsSync).toHaveBeenCalledWith('path/to/project/AI.md');
expect(fs.readFileSync).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,238 @@
import axios from 'axios';
import * as path from 'path';
import { PullRequestService } from '../pull-request-service';
import { Project } from '../../types';
// Mock axios and path modules
jest.mock('axios');
jest.mock('path');
describe('PullRequestService', () => {
let pullRequestService: PullRequestService;
let mockAxiosPost: jest.SpyInstance;
beforeEach(() => {
pullRequestService = new PullRequestService();
// Reset all mocks
jest.resetAllMocks();
// Mock path.join and path.basename
(path.join as jest.Mock).mockImplementation((...args) => args.join('/'));
(path.basename as jest.Mock).mockImplementation((p, ext) => {
const base = p.split('/').pop();
return ext && base.endsWith(ext) ? base.slice(0, -ext.length) : base;
});
// Mock axios.post
mockAxiosPost = jest.spyOn(axios, 'post').mockResolvedValue({
data: {
html_url: 'https://github.com/org/repo/pull/123'
}
});
});
describe('createPullRequest', () => {
it('should create a pull request on GitHub', async () => {
const project: Project = {
name: 'project1',
path: '/path/to/project1',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/repo.git',
targetBranch: 'main'
};
const branchName = 'feature/branch';
const credentials = {
type: 'token' as const,
token: 'mock-token'
};
const title = 'Test PR Title';
const description = 'Test PR Description';
const result = await pullRequestService.createPullRequest(
project,
branchName,
credentials,
title,
description
);
expect(result).toBe('https://github.com/org/repo/pull/123');
expect(mockAxiosPost).toHaveBeenCalledWith(
'https://api.github.com/repos/org/repo/pulls',
{
title,
body: description,
head: branchName,
base: 'main'
},
{
headers: {
'Accept': 'application/vnd.github.v3+json',
'Authorization': 'token mock-token'
}
}
);
});
it('should create a pull request on Gitea', async () => {
const project: Project = {
name: 'project1',
path: '/path/to/project1',
repoHost: 'https://gitea.example.com',
repoUrl: 'https://gitea.example.com/org/repo.git',
targetBranch: 'main'
};
const branchName = 'feature/branch';
const credentials = {
type: 'username-password' as const,
username: 'user',
password: 'pass'
};
const title = 'Test PR Title';
const description = 'Test PR Description';
const result = await pullRequestService.createPullRequest(
project,
branchName,
credentials,
title,
description
);
expect(result).toBe('https://github.com/org/repo/pull/123');
expect(mockAxiosPost).toHaveBeenCalledWith(
'https://gitea.example.com/api/v1/repos/org/repo/pulls',
{
title,
body: description,
head: branchName,
base: 'main'
},
{
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': expect.stringContaining('Basic')
}
}
);
});
it('should use default title if not provided', async () => {
const project: Project = {
name: 'project1',
path: '/path/to/project1',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/repo.git',
targetBranch: 'main'
};
const branchName = 'feature/branch';
const credentials = {
type: 'token' as const,
token: 'mock-token'
};
const description = 'Test PR Description';
const result = await pullRequestService.createPullRequest(
project,
branchName,
credentials,
undefined,
description
);
expect(result).toBe('https://github.com/org/repo/pull/123');
expect(mockAxiosPost).toHaveBeenCalledWith(
'https://api.github.com/repos/org/repo/pulls',
{
title: expect.stringContaining('Update:'),
body: description,
head: branchName,
base: 'main'
},
expect.any(Object)
);
});
it('should use empty string for description if not provided', async () => {
const project: Project = {
name: 'project1',
path: '/path/to/project1',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/repo.git',
targetBranch: 'main'
};
const branchName = 'feature/branch';
const credentials = {
type: 'token' as const,
token: 'mock-token'
};
const title = 'Test PR Title';
const result = await pullRequestService.createPullRequest(
project,
branchName,
credentials,
title
);
expect(result).toBe('https://github.com/org/repo/pull/123');
expect(mockAxiosPost).toHaveBeenCalledWith(
'https://api.github.com/repos/org/repo/pulls',
{
title,
body: '',
head: branchName,
base: 'main'
},
expect.any(Object)
);
});
it('should throw error if repository information is not found', async () => {
const project: Project = {
name: 'project1',
path: '/path/to/project1'
};
const branchName = 'feature/branch';
const credentials = {
type: 'token' as const,
token: 'mock-token'
};
await expect(pullRequestService.createPullRequest(
project,
branchName,
credentials
)).rejects.toThrow('Repository information not found for project project1');
});
it('should throw error if repository host is not supported', async () => {
const project: Project = {
name: 'project1',
path: '/path/to/project1',
repoHost: 'https://unsupported.example.com',
repoUrl: 'https://unsupported.example.com/org/repo.git'
};
const branchName = 'feature/branch';
const credentials = {
type: 'token' as const,
token: 'mock-token'
};
await expect(pullRequestService.createPullRequest(
project,
branchName,
credentials
)).rejects.toThrow('Unsupported repository host: https://unsupported.example.com');
});
});
});

View File

@ -0,0 +1,230 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { simpleGit, SimpleGit } from 'simple-git';
import { RepositoryService } from '../repository-service';
import { Project } from '../../types';
// Mock fs, path, os, and simple-git modules
jest.mock('fs');
jest.mock('path');
jest.mock('os');
jest.mock('simple-git');
describe('RepositoryService', () => {
let repositoryService: RepositoryService;
let mockGit: jest.Mocked<SimpleGit>;
beforeEach(() => {
// Reset all mocks
jest.resetAllMocks();
// Mock path.join to return predictable paths
(path.join as jest.Mock).mockImplementation((...args) => args.join('/'));
// Mock os.tmpdir to return a predictable path
(os.tmpdir as jest.Mock).mockReturnValue('/tmp');
// Mock simpleGit to return a mock SimpleGit instance
mockGit = {
clone: jest.fn().mockResolvedValue(undefined),
checkoutLocalBranch: jest.fn().mockResolvedValue(undefined),
add: jest.fn().mockResolvedValue(undefined),
commit: jest.fn().mockResolvedValue(undefined),
push: jest.fn().mockResolvedValue(undefined),
diff: jest.fn().mockResolvedValue('mock diff'),
status: jest.fn().mockResolvedValue({ files: ['file1', 'file2'], not_added: [] }),
checkout: jest.fn().mockResolvedValue(undefined),
addConfig: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<SimpleGit>;
(simpleGit as jest.Mock).mockReturnValue(mockGit);
// Mock fs.existsSync to return false for the base directory
(fs.existsSync as jest.Mock).mockReturnValue(false);
// Mock fs.mkdirSync
(fs.mkdirSync as jest.Mock).mockImplementation(() => {});
// Create repository service
repositoryService = new RepositoryService();
});
describe('constructor', () => {
it('should create base directory if it does not exist', () => {
expect(fs.existsSync).toHaveBeenCalledWith('/tmp/shared-repo-service');
expect(fs.mkdirSync).toHaveBeenCalledWith('/tmp/shared-repo-service', { recursive: true });
});
it('should use provided base directory if specified', () => {
const customBaseDir = '/custom/base/dir';
repositoryService = new RepositoryService(customBaseDir);
expect(fs.existsSync).toHaveBeenCalledWith(customBaseDir);
expect(fs.mkdirSync).toHaveBeenCalledWith(customBaseDir, { recursive: true });
});
});
describe('cloneMainRepository', () => {
it('should clone the main repository', async () => {
const repoUrl = 'https://github.com/org/repo.git';
const repoDir = '/tmp/shared-repo-service/main-repo';
// Mock fs.existsSync to return true for the repo directory
(fs.existsSync as jest.Mock).mockReturnValueOnce(true);
// Mock fs.rmSync
(fs.rmSync as jest.Mock).mockImplementationOnce(() => {});
const result = await repositoryService.cloneMainRepository(repoUrl);
expect(result).toBe(repoDir);
expect(fs.existsSync).toHaveBeenCalledWith(repoDir);
expect(fs.rmSync).toHaveBeenCalledWith(repoDir, { recursive: true, force: true });
expect(fs.mkdirSync).toHaveBeenCalledWith(repoDir, { recursive: true });
expect(simpleGit).toHaveBeenCalledWith(repoDir);
expect(mockGit.clone).toHaveBeenCalledWith(repoUrl, repoDir);
});
it('should configure git with credentials if provided', async () => {
const repoUrl = 'https://github.com/org/repo.git';
const credentials = {
type: 'token' as const,
token: 'mock-token'
};
await repositoryService.cloneMainRepository(repoUrl, credentials);
expect(mockGit.addConfig).toHaveBeenCalledWith(
'credential.helper',
expect.stringContaining('password=mock-token'),
false,
'global'
);
});
});
describe('cloneProjectRepository', () => {
it('should clone the project repository', async () => {
const project: Project = {
name: 'project1',
path: '/path/to/project1',
repoUrl: 'https://github.com/org/project1.git',
targetBranch: 'main'
};
const projectRepoDir = '/tmp/shared-repo-service/project-project1';
// Mock fs.existsSync to return true for the repo directory
(fs.existsSync as jest.Mock).mockReturnValueOnce(true);
// Mock fs.rmSync
(fs.rmSync as jest.Mock).mockImplementationOnce(() => {});
const result = await repositoryService.cloneProjectRepository(project);
expect(result).toBe(projectRepoDir);
expect(fs.existsSync).toHaveBeenCalledWith(projectRepoDir);
expect(fs.rmSync).toHaveBeenCalledWith(projectRepoDir, { recursive: true, force: true });
expect(fs.mkdirSync).toHaveBeenCalledWith(projectRepoDir, { recursive: true });
expect(simpleGit).toHaveBeenCalledWith(projectRepoDir);
expect(mockGit.clone).toHaveBeenCalledWith(project.repoUrl, projectRepoDir);
expect(mockGit.checkout).toHaveBeenCalledWith(project.targetBranch);
});
it('should throw error if project has no repository URL', async () => {
const project: Project = {
name: 'project1',
path: '/path/to/project1'
};
await expect(repositoryService.cloneProjectRepository(project))
.rejects.toThrow('Repository URL not found for project project1');
});
});
describe('createBranch', () => {
it('should create a new branch', async () => {
const repoDir = '/path/to/repo';
const branchName = 'feature/new-branch';
await repositoryService.createBranch(repoDir, branchName);
expect(simpleGit).toHaveBeenCalledWith(repoDir);
expect(mockGit.checkoutLocalBranch).toHaveBeenCalledWith(branchName);
});
});
describe('commitChanges', () => {
it('should commit changes to the repository', async () => {
const repoDir = '/path/to/repo';
const message = 'Commit message';
await repositoryService.commitChanges(repoDir, message);
expect(simpleGit).toHaveBeenCalledWith(repoDir);
expect(mockGit.add).toHaveBeenCalledWith('.');
expect(mockGit.commit).toHaveBeenCalledWith(message);
});
});
describe('pushChanges', () => {
it('should push changes to the repository', async () => {
const repoDir = '/path/to/repo';
const branchName = 'feature/branch';
await repositoryService.pushChanges(repoDir, branchName);
expect(simpleGit).toHaveBeenCalledWith(repoDir);
expect(mockGit.push).toHaveBeenCalledWith('origin', branchName, ['--set-upstream']);
});
});
describe('generateGitPatch', () => {
it('should generate a git patch of the changes', async () => {
const repoDir = '/path/to/repo';
const result = await repositoryService.generateGitPatch(repoDir);
expect(simpleGit).toHaveBeenCalledWith(repoDir);
expect(mockGit.status).toHaveBeenCalled();
expect(mockGit.diff).toHaveBeenCalledWith(['--staged', '--no-color']);
expect(result).toBe('mock diff');
});
it('should return "No changes detected" if there are no changes', async () => {
const repoDir = '/path/to/repo';
// Mock status to return no files
mockGit.status.mockResolvedValueOnce({ files: [], not_added: [] } as any);
const result = await repositoryService.generateGitPatch(repoDir);
expect(result).toBe('No changes detected.');
});
});
describe('checkoutBranch', () => {
it('should checkout an existing branch', async () => {
const repoDir = '/path/to/repo';
const branchName = 'feature/branch';
await repositoryService.checkoutBranch(repoDir, branchName);
expect(simpleGit).toHaveBeenCalledWith(repoDir);
expect(mockGit.checkout).toHaveBeenCalledWith(branchName);
});
it('should throw error if checkout fails', async () => {
const repoDir = '/path/to/repo';
const branchName = 'feature/branch';
const error = new Error('Checkout failed');
// Mock checkout to throw an error
mockGit.checkout.mockRejectedValueOnce(error);
await expect(repositoryService.checkoutBranch(repoDir, branchName))
.rejects.toThrow(`Failed to checkout branch ${branchName}: ${error.message}`);
});
});
});

View File

@ -0,0 +1,95 @@
/**
* Service for handling project operations
*/
import * as fs from 'fs';
import * as path from 'path';
import { Project } from '../types';
export class ProjectService {
/**
* Find all projects in the prompts directory
* @param promptsDir Path to the prompts directory
* @param functionName Name of the function to find projects for
* @returns Array of projects
*/
async findProjects(promptsDir: string, functionName: string): Promise<Project[]> {
const projects: Project[] = [];
// Check if prompts directory exists
if (!fs.existsSync(promptsDir)) {
return projects;
}
const functionPath = path.join(promptsDir, functionName);
// Check if function directory exists
if (!fs.existsSync(functionPath)) {
return projects;
}
// Get all project directories in the function directory
const projectEntries = fs.readdirSync(functionPath, { withFileTypes: true });
const projectDirs = projectEntries.filter(entry => entry.isDirectory());
for (const dir of projectDirs) {
const projectPath = path.join(functionPath, dir.name);
const infoPath = path.join(projectPath, 'INFO.md');
// Skip directories without INFO.md
if (!fs.existsSync(infoPath)) {
continue;
}
// Read project info
const project = await this.readProjectInfo(projectPath, dir.name);
projects.push(project);
}
return projects;
}
/**
* Read project information from INFO.md
* @param projectPath Path to the project directory
* @param projectName Name of the project
* @returns Project information
*/
async readProjectInfo(projectPath: string, projectName: string): Promise<Project> {
const infoPath = path.join(projectPath, 'INFO.md');
const infoContent = fs.readFileSync(infoPath, 'utf-8');
// Parse INFO.md content
const repoHostMatch = infoContent.match(/- \[[ x]\] Repo host: (.*)/);
const repoUrlMatch = infoContent.match(/- \[[ x]\] Repo url: (.*)/);
const targetBranchMatch = infoContent.match(/- \[[ x]\] Target branch: (.*)/);
const jiraComponentMatch = infoContent.match(/- \[[ x]\] Jira component: (.*)/);
const aiGuidelinesMatch = infoContent.match(/- \[[ x]\] AI guidelines: (.*)/);
const project = {
name: projectName,
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,
aiGuidelines: aiGuidelinesMatch ? aiGuidelinesMatch[1].trim() : undefined
};
return project;
}
/**
* Read AI guidelines for a project
* @param projectPath Path to the project directory
* @returns AI guidelines content
*/
async readProjectGuidelines(projectPath: string): Promise<string> {
const aiPath = path.join(projectPath, 'AI.md');
if (!fs.existsSync(aiPath)) {
return '';
}
return fs.readFileSync(aiPath, 'utf-8');
}
}

View File

@ -0,0 +1,145 @@
/**
* Service for handling pull request operations
*/
import axios from 'axios';
import * as path from 'path';
import {Project, RepoCredentials} from '../types';
export class PullRequestService {
/**
* Create a pull request for changes in a repository
* @param project Project information
* @param branchName Name of the branch with changes
* @param credentials Repository credentials
* @param title Optional pull request title
* @param description Optional custom pull request description
* @returns URL of the created pull request
*/
async createPullRequest(
project: Project,
branchName: string,
credentials: RepoCredentials,
title?: string,
description?: string
): Promise<string> {
if (!project.repoHost || !project.repoUrl) {
throw new Error(`Repository information not found for project ${project.name}`);
}
// Generate default PR title if not provided
const prTitle = title || `Update: ${new Date().toISOString().split('T')[0]}`;
// Determine the repository host type and create PR accordingly
if (project.repoHost.includes('github.com')) {
return this.createGithubPullRequest(project, branchName, prTitle, description || '', credentials);
} else if (project.repoHost.includes('gitea')) {
return this.createGiteaPullRequest(project, branchName, prTitle, description || '', credentials);
} else {
throw new Error(`Unsupported repository host: ${project.repoHost}`);
}
}
/**
* Create a pull request on GitHub
* @param project Project information
* @param branchName Name of the branch with changes
* @param title Pull request title
* @param description Pull request description
* @param credentials Repository credentials
* @returns URL of the created pull request
*/
private async createGithubPullRequest(
project: Project,
branchName: string,
title: string,
description: string,
credentials: RepoCredentials
): Promise<string> {
// Extract owner and repo from the repository URL
const repoUrlParts = project.repoUrl!.split('/');
const repo = path.basename(repoUrlParts[repoUrlParts.length - 1], '.git');
const owner = repoUrlParts[repoUrlParts.length - 2];
// Create the pull request
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls`;
const headers: Record<string, string> = {
'Accept': 'application/vnd.github.v3+json',
};
if (credentials.type === 'token' && credentials.token) {
headers['Authorization'] = `token ${credentials.token}`;
} else if (credentials.type === 'username-password' && credentials.username && credentials.password) {
const auth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
headers['Authorization'] = `Basic ${auth}`;
} else {
throw new Error('Invalid credentials for GitHub');
}
const response = await axios.post(
apiUrl,
{
title,
body: description,
head: branchName,
base: project.targetBranch || 'main', // Use target branch from project info or default to 'main'
},
{headers}
);
return response.data.html_url;
}
/**
* Create a pull request on Gitea
* @param project Project information
* @param branchName Name of the branch with changes
* @param title Pull request title
* @param description Pull request description
* @param credentials Repository credentials
* @returns URL of the created pull request
*/
private async createGiteaPullRequest(
project: Project,
branchName: string,
title: string,
description: string,
credentials: RepoCredentials
): Promise<string> {
// Extract owner and repo from the repository URL
const repoUrlParts = project.repoUrl!.split('/');
const repo = path.basename(repoUrlParts[repoUrlParts.length - 1], '.git');
const owner = repoUrlParts[repoUrlParts.length - 2];
// Create the pull request
const apiUrl = `${project.repoHost}/api/v1/repos/${owner}/${repo}/pulls`;
const headers: Record<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
if (credentials.type === 'token' && credentials.token) {
headers['Authorization'] = `token ${credentials.token}`;
} else if (credentials.type === 'username-password' && credentials.username && credentials.password) {
const auth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
headers['Authorization'] = `Basic ${auth}`;
} else {
throw new Error('Invalid credentials for Gitea');
}
const response = await axios.post(
apiUrl,
{
title,
body: description,
head: branchName,
base: project.targetBranch || 'main', // Use target branch from project info or default to 'main'
},
{headers}
);
return response.data.html_url;
}
}

View File

@ -0,0 +1,185 @@
/**
* Service for handling repository operations
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { simpleGit, SimpleGit } from 'simple-git';
import { Project, RepoCredentials } from '../types';
export class RepositoryService {
private baseDir: string;
constructor(baseDir?: string) {
this.baseDir = baseDir || path.join(os.tmpdir(), 'shared-repo-service');
// Ensure base directory exists
if (!fs.existsSync(this.baseDir)) {
fs.mkdirSync(this.baseDir, { recursive: true });
}
}
/**
* Clone the main repository containing prompts
* @param repoUrl URL of the repository
* @param credentials Optional credentials for private repositories
* @returns Path to the cloned repository
*/
async cloneMainRepository(repoUrl: string, credentials?: RepoCredentials): Promise<string> {
const repoDir = path.join(this.baseDir, 'main-repo');
// Clean up existing directory if it exists
if (fs.existsSync(repoDir)) {
fs.rmSync(repoDir, { recursive: true, force: true });
}
fs.mkdirSync(repoDir, { recursive: true });
// Configure git with credentials if provided
const git = this.configureGit(repoDir, credentials);
// Clone the repository
await git.clone(repoUrl, repoDir);
return repoDir;
}
/**
* Clone a project repository
* @param project Project information
* @param credentials Optional credentials for private repositories
* @returns Path to the cloned repository
*/
async cloneProjectRepository(project: Project, credentials?: RepoCredentials): Promise<string> {
if (!project.repoUrl) {
throw new Error(`Repository URL not found for project ${project.name}`);
}
const projectRepoDir = path.join(this.baseDir, `project-${project.name}`);
// Clean up existing directory if it exists
if (fs.existsSync(projectRepoDir)) {
fs.rmSync(projectRepoDir, { recursive: true, force: true });
}
fs.mkdirSync(projectRepoDir, { recursive: true });
// Configure git with credentials if provided
const git = this.configureGit(projectRepoDir, credentials);
// 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;
}
/**
* Create a new branch in a repository
* @param repoDir Path to the repository
* @param branchName Name of the branch to create
*/
async createBranch(repoDir: string, branchName: string): Promise<void> {
const git = simpleGit(repoDir);
await git.checkoutLocalBranch(branchName);
}
/**
* Commit changes to a repository
* @param repoDir Path to the repository
* @param message Commit message
*/
async commitChanges(repoDir: string, message: string): Promise<void> {
const git = simpleGit(repoDir);
await git.add('.');
await git.commit(message);
}
/**
* Push changes to a repository
* @param repoDir Path to the repository
* @param branchName Name of the branch to push
* @param credentials Optional credentials for private repositories
*/
async pushChanges(repoDir: string, branchName: string, credentials?: RepoCredentials): Promise<void> {
const git = this.configureGit(repoDir, credentials);
await git.push('origin', branchName, ['--set-upstream']);
}
/**
* Generate a git patch of the changes in a repository
* @param repoDir Path to the repository
* @returns Git patch as a string
*/
async generateGitPatch(repoDir: string): Promise<string> {
const git = simpleGit(repoDir);
// Check if there are any changes
const status = await git.status();
if (status.files.length === 0) {
return "No changes detected.";
}
// Generate a diff of all changes (staged and unstaged)
const diff = await git.diff(['--staged', '--no-color']);
// Only get untracked diff if there are untracked files
let untrackedDiff = '';
if (status.not_added && status.not_added.length > 0) {
untrackedDiff = await git.diff(['--no-index', '/dev/null', ...status.not_added.map(file => path.join(repoDir, file))]).catch(() => '');
}
// Combine the diffs
let patch = diff;
if (untrackedDiff) {
patch += '\n\n' + untrackedDiff;
}
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
* @param repoDir Path to the repository
* @param credentials Credentials for authentication
* @returns Configured SimpleGit instance
*/
private configureGit(repoDir: string, credentials?: RepoCredentials): SimpleGit {
const git = simpleGit(repoDir);
if (credentials) {
if (credentials.type === 'username-password' && credentials.username && credentials.password) {
// For HTTPS URLs with username/password
const credentialHelper = `!f() { echo "username=${credentials.username}"; echo "password=${credentials.password}"; }; f`;
git.addConfig('credential.helper', credentialHelper, false, 'global');
} else if (credentials.type === 'token' && credentials.token) {
// For HTTPS URLs with token
const credentialHelper = `!f() { echo "password=${credentials.token}"; }; f`;
git.addConfig('credential.helper', credentialHelper, false, 'global');
}
}
return git;
}
}

View File

@ -0,0 +1,45 @@
/**
* Common type definitions for shared utilities
*/
export interface Project {
name: string;
path: string;
repoHost?: string;
repoUrl?: string;
jiraComponent?: string;
targetBranch?: string;
aiGuidelines?: string;
}
export interface Workitem {
name: string;
path: string;
title: string;
description: string;
jiraReference?: string;
implementation?: string;
pullRequestUrl?: string;
isActive: boolean;
}
export interface RepoCredentials {
type: 'username-password' | 'token';
username?: string;
password?: string;
token?: string;
}
export interface ProcessResult {
project: Project;
processedWorkitems: {
workitem: Workitem;
success: boolean;
error?: string;
status?: 'skipped' | 'updated' | 'created';
filesWritten?: string[];
}[];
pullRequestUrl?: string;
error?: string;
gitPatch?: string;
}

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"declaration": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}

View File

@ -23,6 +23,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 url: <url of the project repository>
- [ ] Target branch: <target branch for the PR>
- [ ] AI guidelines: <path to ai guidelines md file in the project repo>
- [ ] Jira component: <component of the jira>
```

View File

@ -5,7 +5,9 @@
- Add comments in the feature file indicating
- The date/time/execution info of the job that created the work item
- The work item prompt file in this directory
- Cumcumber spec should be succinct and deterministic. Avoid words like "should" and "should have", prefer "must" and "must have".
- The jira ticket number, and a link to the jira ticket if applicable.
- Cumcumber spec should be succinct and deterministic. Avoid words like "should" and "should have", prefer "must" and "
must have".
- Inactive work items should have their feature file deleted.
- Updates should be committed to a new branch and a pull request should be created.s
- The pull request should include a short description of the modified code

View File

@ -5,4 +5,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
- [ ] AI guidelines:
- [x] Jira component: nitro

View File

@ -4,12 +4,16 @@ This is a test workitem.
The nitro-back backend should have a /test endpoint implemented returning the json object: {"test": "Hellow"}.
- [ ] Jira:
- [ ] Jira: NITRO-0001
- [ ] Implementation:
- [x] Active
### Log
2025-06-08T03:25:45.195Z - Workitem has been implemented. Created files:
- nitro-it/src/test/resources/workitems/2025-06-08-test.feature
2025-06-08T03:00:46.571Z - Workitem has been implemented. Created files:
No files were affected.

View File

@ -0,0 +1,3 @@
This function creates test specs from prompts.
each workitem contains a prompt for a test spec.

View File

@ -0,0 +1,46 @@
This file describes the AI guidelines for operations in this directory.
## Directory structure
- <project>/: A single project repository
- INFO.md: Project information, including where the code is hosted
- AI.md: AI guidelines for the project
### File format
File format is markdown.
It contains checkboxes, that must only be checked if the information is available and provided.
#### Project info file format
A project info file follows the following format:
```markdown
## <Project name>
- [ ] 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>
- [ ] Target branch: <target branch for the PR>
- [ ] AI guidelines: <path to ai guidelines md file in the project repo>
- [ ] Jira component: <component of the jira>
```
### Credentials
This section describes credentials to use when interacting with various apis and services.
The actual credentials are provided in the environment variables.
#### Jira
#### Github
- [ ] host: https://github.com/organizations/Ebitda-SRL
#### Gitea
- [x] host: https://gitea.fteamdev.valuya.be
- credential type: username/password
- username variable: GITEA_USERNAME
- password variable: GITEA_PASSWORD

View File

@ -0,0 +1,7 @@
## Test spec implementation
- Iterate over cucumber feature definitions in the `nitro-it/src/test/resources/workitems/` folder.
- For each of them, a corresponding test implementation should be created if it does not exist.
- Test implementations should be created in the `nitro-it/src/test/java/be/fiscalteam/nitro/bdd` folder, following the
same structure as the feature definition files. One test file per feature definition.

View File

@ -0,0 +1,9 @@
# Nitro-back
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
- [ ] AI guidelines:
- [x] Jira component: nitro

View File

@ -0,0 +1 @@
This function reads unimplemented test specs, and generates test implementations.