WIP
This commit is contained in:
parent
01bb760c9a
commit
c160d9bc5e
4
.idea/test-ai-code-agents.iml
generated
4
.idea/test-ai-code-agents.iml
generated
@ -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>
|
||||
|
@ -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
|
||||
|
||||
|
24
src/functions/prompts-to-test-spec/package-lock.json
generated
24
src/functions/prompts-to-test-spec/package-lock.json
generated
@ -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",
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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
4
src/functions/shared/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
coverage/
|
123
src/functions/shared/README.md
Normal file
123
src/functions/shared/README.md
Normal 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
|
||||
```
|
12
src/functions/shared/jest.config.js
Normal file
12
src/functions/shared/jest.config.js
Normal 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
5052
src/functions/shared/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
src/functions/shared/package.json
Normal file
30
src/functions/shared/package.json
Normal 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"
|
||||
]
|
||||
}
|
11
src/functions/shared/src/index.ts
Normal file
11
src/functions/shared/src/index.ts
Normal 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';
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
@ -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}`);
|
||||
});
|
||||
});
|
||||
});
|
95
src/functions/shared/src/services/project-service.ts
Normal file
95
src/functions/shared/src/services/project-service.ts
Normal 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');
|
||||
}
|
||||
}
|
145
src/functions/shared/src/services/pull-request-service.ts
Normal file
145
src/functions/shared/src/services/pull-request-service.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
185
src/functions/shared/src/services/repository-service.ts
Normal file
185
src/functions/shared/src/services/repository-service.ts
Normal 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;
|
||||
}
|
||||
}
|
45
src/functions/shared/src/types.ts
Normal file
45
src/functions/shared/src/types.ts
Normal 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;
|
||||
}
|
14
src/functions/shared/tsconfig.json
Normal file
14
src/functions/shared/tsconfig.json
Normal 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"]
|
||||
}
|
@ -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>
|
||||
|
||||
```
|
@ -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
|
@ -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
|
@ -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.
|
3
src/prompts/prompts-to-test-spec/prompts-to-test-spec.md
Normal file
3
src/prompts/prompts-to-test-spec/prompts-to-test-spec.md
Normal file
@ -0,0 +1,3 @@
|
||||
This function creates test specs from prompts.
|
||||
|
||||
each workitem contains a prompt for a test spec.
|
46
src/prompts/test-spec-to-test-implementation/AI.md
Normal file
46
src/prompts/test-spec-to-test-implementation/AI.md
Normal 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
|
@ -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.
|
||||
|
@ -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
|
@ -0,0 +1 @@
|
||||
This function reads unimplemented test specs, and generates test implementations.
|
Loading…
x
Reference in New Issue
Block a user