This commit is contained in:
cghislai 2025-06-08 08:17:52 +02:00
parent ce388e07e4
commit 6568ff640d
15 changed files with 416 additions and 595 deletions

View File

@ -28,9 +28,9 @@ export function formatHttpResponse(results: ProcessResult[]): HttpResponse {
const projects: ProjectSummary[] = results.map(result => {
// Count workitems
const workitemsProcessed = result.processedWorkitems.length;
const workitemsSkipped = result.processedWorkitems.filter(w => !w.workitem.isActive).length;
const workitemsUpdated = result.processedWorkitems.filter(w => w.success).length;
const workitemsCreated = result.processedWorkitems.filter(w => w.success && w.status === 'created').length;
const workitemsSkipped = result.processedWorkitems.filter(w => w.success && w.status === "skip").length;
const workitemsUpdated = result.processedWorkitems.filter(w => w.success && w.status === "update").length;
const workitemsCreated = result.processedWorkitems.filter(w => w.success && w.status === 'create').length;
const filesWritten = result.processedWorkitems.reduce((sum, w) => sum + (w.filesWritten?.length || 0), 0);
return {

View File

@ -21,7 +21,8 @@ describe('formatHttpResponse', () => {
description: 'Description 1',
isActive: true
},
success: true
success: true,
status: 'update'
},
{
workitem: {
@ -31,7 +32,8 @@ describe('formatHttpResponse', () => {
description: 'Description 2',
isActive: false
},
success: true
success: true,
status: 'update'
}
],
pullRequestUrl: 'https://github.com/org/project1/pull/123'
@ -74,8 +76,9 @@ describe('formatHttpResponse', () => {
{
name: 'project1',
success: true,
error: undefined,
workitemsProcessed: 2,
workitemsSkipped: 1,
workitemsSkipped: 0,
workitemsUpdated: 2,
workitemsCreated: 0,
filesWritten: 0,
@ -89,7 +92,8 @@ describe('formatHttpResponse', () => {
workitemsSkipped: 0,
workitemsUpdated: 0,
workitemsCreated: 0,
filesWritten: 0
filesWritten: 0,
pullRequestUrl: undefined
}
]
});

View File

@ -1,269 +1,267 @@
import * as fs from 'fs';
import * as path from 'path';
import { ProcessorService } from '../processor-service';
import { ProjectService } from '../project-service';
import { Project, Workitem, ProcessResult } from '../../types';
import {ProcessorService} from '../processor-service';
import {ProjectService} from '../project-service';
import {ProcessResult, Workitem} from '../../types';
import {
RepositoryService as SharedRepositoryService,
PullRequestService as SharedPullRequestService,
GeminiService
GeminiService, Project,
PullRequestService as SharedPullRequestService,
RepositoryService as SharedRepositoryService
} from 'shared-functions';
// Mock dependencies
jest.mock('../project-service');
jest.mock('shared-functions');
jest.mock('../../config', () => ({
validateConfig: jest.fn(),
getMainRepoCredentials: jest.fn().mockReturnValue({ type: 'token', token: 'mock-token' }),
getGithubCredentials: jest.fn().mockReturnValue({ type: 'token', token: 'mock-token' }),
getGiteaCredentials: jest.fn().mockReturnValue({ type: 'token', token: 'mock-token' }),
MAIN_REPO_URL: 'https://github.com/org/main-repo.git',
GOOGLE_CLOUD_PROJECT_ID: 'mock-project-id',
GOOGLE_CLOUD_LOCATION: 'mock-location',
GEMINI_MODEL: 'mock-model',
USE_LOCAL_REPO: false,
DRY_RUN_SKIP_COMMITS: false,
DRY_RUN_SKIP_GEMINI: false
validateConfig: jest.fn(),
getMainRepoCredentials: jest.fn().mockReturnValue({type: 'token', token: 'mock-token'}),
getGithubCredentials: jest.fn().mockReturnValue({type: 'token', token: 'mock-token'}),
getGiteaCredentials: jest.fn().mockReturnValue({type: 'token', token: 'mock-token'}),
MAIN_REPO_URL: 'https://github.com/org/main-repo.git',
GOOGLE_CLOUD_PROJECT_ID: 'mock-project-id',
GOOGLE_CLOUD_LOCATION: 'mock-location',
GEMINI_MODEL: 'mock-model',
USE_LOCAL_REPO: false,
DRY_RUN_SKIP_COMMITS: false,
DRY_RUN_SKIP_GEMINI: false
}));
describe('ProcessorService', () => {
let processorService: ProcessorService;
let mockProjectService: jest.Mocked<ProjectService>;
let mockSharedRepositoryService: jest.Mocked<SharedRepositoryService>;
let mockSharedPullRequestService: jest.Mocked<SharedPullRequestService>;
let mockGeminiService: jest.Mocked<GeminiService>;
let processorService: ProcessorService;
let mockProjectService: jest.Mocked<ProjectService>;
let mockSharedRepositoryService: jest.Mocked<SharedRepositoryService>;
let mockSharedPullRequestService: jest.Mocked<SharedPullRequestService>;
let mockGeminiService: jest.Mocked<GeminiService>;
beforeEach(() => {
jest.clearAllMocks();
processorService = new ProcessorService();
mockProjectService = ProjectService.prototype as jest.Mocked<ProjectService>;
mockSharedRepositoryService = SharedRepositoryService.prototype as jest.Mocked<SharedRepositoryService>;
mockSharedPullRequestService = SharedPullRequestService.prototype as jest.Mocked<SharedPullRequestService>;
mockGeminiService = GeminiService.prototype as jest.Mocked<GeminiService>;
});
describe('updateWorkitemFilesWithPullRequestUrls', () => {
it('should update workitem files with pull request URLs and commit changes', async () => {
// Create test data
const mainRepoPath = '/path/to/main/repo';
const project: Project = {
name: 'test-project',
path: '/path/to/project',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/test-project.git'
};
const workitem1: Workitem = {
name: 'workitem1',
path: '/path/to/workitem1.md',
title: 'Workitem 1',
description: 'Description 1',
isActive: true
};
const workitem2: Workitem = {
name: 'workitem2',
path: '/path/to/workitem2.md',
title: 'Workitem 2',
description: 'Description 2',
isActive: true
};
const results: ProcessResult[] = [
{
project,
processedWorkitems: [
{ workitem: workitem1, success: true, status: 'updated', filesWritten: [] },
{ workitem: workitem2, success: true, status: 'updated', filesWritten: [] }
],
pullRequestUrl: 'https://github.com/org/test-project/pull/123',
gitPatch: 'mock-git-patch'
}
];
// Mock the updateWorkitemWithPullRequestUrl method
mockProjectService.updateWorkitemWithPullRequestUrl.mockImplementation(
async (workitem, pullRequestUrl) => {
return { ...workitem, pullRequestUrl };
}
);
// Call the method
await (processorService as any).updateWorkitemFilesWithPullRequestUrls(results, mainRepoPath);
// Verify the method calls
expect(mockSharedRepositoryService.createBranch).toHaveBeenCalledWith(
mainRepoPath,
expect.stringMatching(/update-workitem-pr-urls-\d{4}-\d{2}-\d{2}/)
);
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalledTimes(2);
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalledWith(
workitem1,
'https://github.com/org/test-project/pull/123'
);
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalledWith(
workitem2,
'https://github.com/org/test-project/pull/123'
);
expect(mockSharedRepositoryService.commitChanges).toHaveBeenCalledWith(
mainRepoPath,
expect.stringMatching(/Update workitem files with pull request URLs: \d{4}-\d{2}-\d{2}/)
);
expect(mockSharedRepositoryService.pushChanges).toHaveBeenCalledWith(
mainRepoPath,
expect.stringMatching(/update-workitem-pr-urls-\d{4}-\d{2}-\d{2}/),
expect.anything()
);
beforeEach(() => {
jest.clearAllMocks();
processorService = new ProcessorService();
mockProjectService = ProjectService.prototype as jest.Mocked<ProjectService>;
mockSharedRepositoryService = SharedRepositoryService.prototype as jest.Mocked<SharedRepositoryService>;
mockSharedPullRequestService = SharedPullRequestService.prototype as jest.Mocked<SharedPullRequestService>;
mockGeminiService = GeminiService.prototype as jest.Mocked<GeminiService>;
});
it('should handle deactivated workitems correctly', async () => {
// Create test data
const mainRepoPath = '/path/to/main/repo';
const project: Project = {
name: 'test-project',
path: '/path/to/project',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/test-project.git'
};
describe('updateWorkitemFilesWithPullRequestUrls', () => {
it('should update workitem files with pull request URLs and commit changes', async () => {
// Create test data
const mainRepoPath = '/path/to/main/repo';
const project: Project = {
name: 'test-project',
path: '/path/to/project',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/test-project.git'
};
const activeWorkitem: Workitem = {
name: 'active-workitem',
path: '/path/to/active-workitem.md',
title: 'Active Workitem',
description: 'This is an active workitem',
isActive: true
};
const workitem1: Workitem = {
name: 'workitem1',
path: '/path/to/workitem1.md',
title: 'Workitem 1',
description: 'Description 1',
isActive: true
};
const deactivatedWorkitem: Workitem = {
name: 'deactivated-workitem',
path: '/path/to/deactivated-workitem.md',
title: 'Deactivated Workitem',
description: 'This is a deactivated workitem',
isActive: false
};
const workitem2: Workitem = {
name: 'workitem2',
path: '/path/to/workitem2.md',
title: 'Workitem 2',
description: 'Description 2',
isActive: true
};
const results: ProcessResult[] = [
{
project,
processedWorkitems: [
{ workitem: activeWorkitem, success: true, status: 'updated', filesWritten: [] },
{ workitem: deactivatedWorkitem, success: true, status: 'skipped', filesWritten: [] }
],
pullRequestUrl: 'https://github.com/org/test-project/pull/123'
}
];
const results: ProcessResult[] = [
{
project,
processedWorkitems: [
{workitem: workitem1, success: true, status: 'update', filesWritten: []},
{workitem: workitem2, success: true, status: 'update', filesWritten: []}
],
pullRequestUrl: 'https://github.com/org/test-project/pull/123',
gitPatch: 'mock-git-patch'
}
];
// Mock the updateWorkitemWithPullRequestUrl method
mockProjectService.updateWorkitemWithPullRequestUrl.mockImplementation(
async (workitem, pullRequestUrl) => {
return { ...workitem, pullRequestUrl };
}
);
// Mock the updateWorkitemWithPullRequestUrl method
mockProjectService.updateWorkitemWithPullRequestUrl.mockImplementation(
async (workitem, pullRequestUrl) => {
return {...workitem, pullRequestUrl};
}
);
// Call the method
await (processorService as any).updateWorkitemFilesWithPullRequestUrls(results, mainRepoPath);
// Call the method
await (processorService as any).updateWorkitemFilesWithPullRequestUrls(results, mainRepoPath);
// Verify the method calls
expect(mockSharedRepositoryService.createBranch).toHaveBeenCalledWith(
mainRepoPath,
expect.stringMatching(/update-workitem-pr-urls-\d{4}-\d{2}-\d{2}/)
);
// Verify the method calls
expect(mockSharedRepositoryService.createBranch).toHaveBeenCalledWith(
mainRepoPath,
expect.stringMatching(/update-workitem-pr-urls-\d{4}-\d{2}-\d{2}/)
);
// Should only update the active workitem
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalledTimes(2);
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalledWith(
activeWorkitem,
'https://github.com/org/test-project/pull/123'
);
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalledWith(
deactivatedWorkitem,
'https://github.com/org/test-project/pull/123'
);
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalledTimes(2);
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalledWith(
workitem1,
'https://github.com/org/test-project/pull/123'
);
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalledWith(
workitem2,
'https://github.com/org/test-project/pull/123'
);
expect(mockSharedRepositoryService.commitChanges).toHaveBeenCalledWith(
mainRepoPath,
expect.stringMatching(/Update workitem files with pull request URLs: \d{4}-\d{2}-\d{2}/)
);
expect(mockSharedRepositoryService.commitChanges).toHaveBeenCalledWith(
mainRepoPath,
expect.stringMatching(/Update workitem files with pull request URLs: \d{4}-\d{2}-\d{2}/)
);
expect(mockSharedRepositoryService.pushChanges).toHaveBeenCalledWith(
mainRepoPath,
expect.stringMatching(/update-workitem-pr-urls-\d{4}-\d{2}-\d{2}/),
expect.anything()
);
expect(mockSharedRepositoryService.pushChanges).toHaveBeenCalledWith(
mainRepoPath,
expect.stringMatching(/update-workitem-pr-urls-\d{4}-\d{2}-\d{2}/),
expect.anything()
);
});
it('should handle deactivated workitems correctly', async () => {
// Create test data
const mainRepoPath = '/path/to/main/repo';
const project: Project = {
name: 'test-project',
path: '/path/to/project',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/test-project.git'
};
const activeWorkitem: Workitem = {
name: 'active-workitem',
path: '/path/to/active-workitem.md',
title: 'Active Workitem',
description: 'This is an active workitem',
isActive: true
};
const deactivatedWorkitem: Workitem = {
name: 'deactivated-workitem',
path: '/path/to/deactivated-workitem.md',
title: 'Deactivated Workitem',
description: 'This is a deactivated workitem',
isActive: false
};
const results: ProcessResult[] = [
{
project,
processedWorkitems: [
{workitem: activeWorkitem, success: true, status: 'update', filesWritten: []},
{workitem: deactivatedWorkitem, success: true, status: 'skip', filesWritten: []}
],
pullRequestUrl: 'https://github.com/org/test-project/pull/123'
}
];
// Mock the updateWorkitemWithPullRequestUrl method
mockProjectService.updateWorkitemWithPullRequestUrl.mockImplementation(
async (workitem, pullRequestUrl) => {
return {...workitem, pullRequestUrl};
}
);
// Call the method
await (processorService as any).updateWorkitemFilesWithPullRequestUrls(results, mainRepoPath);
// Verify the method calls
expect(mockSharedRepositoryService.createBranch).toHaveBeenCalledWith(
mainRepoPath,
expect.stringMatching(/update-workitem-pr-urls-\d{4}-\d{2}-\d{2}/)
);
// Should only update the active workitem
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalledTimes(2);
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalledWith(
activeWorkitem,
'https://github.com/org/test-project/pull/123'
);
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalledWith(
deactivatedWorkitem,
'https://github.com/org/test-project/pull/123'
);
expect(mockSharedRepositoryService.commitChanges).toHaveBeenCalledWith(
mainRepoPath,
expect.stringMatching(/Update workitem files with pull request URLs: \d{4}-\d{2}-\d{2}/)
);
expect(mockSharedRepositoryService.pushChanges).toHaveBeenCalledWith(
mainRepoPath,
expect.stringMatching(/update-workitem-pr-urls-\d{4}-\d{2}-\d{2}/),
expect.anything()
);
});
it('should not commit changes if no workitems were updated', async () => {
// Create test data with no pull request URL
const mainRepoPath = '/path/to/main/repo';
const project: Project = {
name: 'test-project',
path: '/path/to/project',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/test-project.git'
};
const results: ProcessResult[] = [
{
project,
processedWorkitems: [],
// No pull request URL
}
];
// Call the method
await (processorService as any).updateWorkitemFilesWithPullRequestUrls(results, mainRepoPath);
// Verify the method calls
expect(mockSharedRepositoryService.createBranch).toHaveBeenCalled();
expect(mockProjectService.updateWorkitemWithPullRequestUrl).not.toHaveBeenCalled();
expect(mockSharedRepositoryService.commitChanges).not.toHaveBeenCalled();
expect(mockSharedRepositoryService.pushChanges).not.toHaveBeenCalled();
});
it('should handle errors when updating workitem files', async () => {
// Create test data
const mainRepoPath = '/path/to/main/repo';
const project: Project = {
name: 'test-project',
path: '/path/to/project',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/test-project.git'
};
const workitem: Workitem = {
name: 'workitem',
path: '/path/to/workitem.md',
title: 'Workitem',
description: 'Description',
isActive: true
};
const results: ProcessResult[] = [
{
project,
processedWorkitems: [
{workitem, success: true}
],
pullRequestUrl: 'https://github.com/org/test-project/pull/123'
}
];
// Mock the updateWorkitemWithPullRequestUrl method to throw an error
mockProjectService.updateWorkitemWithPullRequestUrl.mockRejectedValueOnce(
new Error('Failed to update workitem')
);
// Call the method
await (processorService as any).updateWorkitemFilesWithPullRequestUrls(results, mainRepoPath);
// Verify the method calls
expect(mockSharedRepositoryService.createBranch).toHaveBeenCalled();
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalled();
expect(mockSharedRepositoryService.commitChanges).not.toHaveBeenCalled();
expect(mockSharedRepositoryService.pushChanges).not.toHaveBeenCalled();
});
});
it('should not commit changes if no workitems were updated', async () => {
// Create test data with no pull request URL
const mainRepoPath = '/path/to/main/repo';
const project: Project = {
name: 'test-project',
path: '/path/to/project',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/test-project.git'
};
const results: ProcessResult[] = [
{
project,
processedWorkitems: [],
// No pull request URL
}
];
// Call the method
await (processorService as any).updateWorkitemFilesWithPullRequestUrls(results, mainRepoPath);
// Verify the method calls
expect(mockSharedRepositoryService.createBranch).toHaveBeenCalled();
expect(mockProjectService.updateWorkitemWithPullRequestUrl).not.toHaveBeenCalled();
expect(mockSharedRepositoryService.commitChanges).not.toHaveBeenCalled();
expect(mockSharedRepositoryService.pushChanges).not.toHaveBeenCalled();
});
it('should handle errors when updating workitem files', async () => {
// Create test data
const mainRepoPath = '/path/to/main/repo';
const project: Project = {
name: 'test-project',
path: '/path/to/project',
repoHost: 'https://github.com',
repoUrl: 'https://github.com/org/test-project.git'
};
const workitem: Workitem = {
name: 'workitem',
path: '/path/to/workitem.md',
title: 'Workitem',
description: 'Description',
isActive: true
};
const results: ProcessResult[] = [
{
project,
processedWorkitems: [
{ workitem, success: true }
],
pullRequestUrl: 'https://github.com/org/test-project/pull/123'
}
];
// Mock the updateWorkitemWithPullRequestUrl method to throw an error
mockProjectService.updateWorkitemWithPullRequestUrl.mockRejectedValueOnce(
new Error('Failed to update workitem')
);
// Call the method
await (processorService as any).updateWorkitemFilesWithPullRequestUrls(results, mainRepoPath);
// Verify the method calls
expect(mockSharedRepositoryService.createBranch).toHaveBeenCalled();
expect(mockProjectService.updateWorkitemWithPullRequestUrl).toHaveBeenCalled();
expect(mockSharedRepositoryService.commitChanges).not.toHaveBeenCalled();
expect(mockSharedRepositoryService.pushChanges).not.toHaveBeenCalled();
});
});
});

View File

@ -3,11 +3,11 @@
*/
import * as path from 'path';
import * as os from 'os';
import {ProcessResult, Project, RepoCredentials} from '../types';
import {ProcessResult, RepoCredentials} from '../types';
import {
RepositoryService as SharedRepositoryService,
PullRequestService as SharedPullRequestService,
GeminiService
GeminiService, Project
} from 'shared-functions';
import {ProjectService} from './project-service';
import {ProjectWorkitemsService} from './project-workitems-service';
@ -284,8 +284,11 @@ export class ProcessorService {
await this.sharedRepositoryService.pushChanges(projectRepoPath, branchName, credentials);
// Generate PR description using Gemini
const workItemsSummary = result.processedWorkitems
.map(item => `${item.workitem.name}: ${item.status} (${item.filesWritten?.length ?? 0} written, ${item.filesRemoved?.length ?? 0} removed)`)
.reduce((acc, item) => `${acc}\n${item}`, '');
const description = await this.geminiService.generatePullRequestDescription(
result.processedWorkitems,
workItemsSummary,
result.gitPatch
);

View File

@ -3,8 +3,8 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import {ProjectService as SharedProjectService, Project, Workitem} from 'shared-functions';
import { WorkitemImplementationStatus } from '../types';
import {Project, ProjectService as SharedProjectService} from 'shared-functions';
import {Workitem, WorkitemImplementationStatus} from '../types';
export class ProjectService {
private sharedProjectService: SharedProjectService;

View File

@ -3,14 +3,13 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import {ProcessResult} from '../types';
import {ProcessedWorkItem, ProcessResult, Workitem} from '../types';
import {ProjectService} from './project-service';
import {DRY_RUN_SKIP_GEMINI} from '../config';
import {
GeminiFileSystemService,
Project,
Workitem,
RepositoryService as SharedRepositoryService
RepositoryService as SharedRepositoryService,
} from 'shared-functions';
export class ProjectWorkitemsService {
@ -50,11 +49,10 @@ export class ProjectWorkitemsService {
const projectGuidelines = await this.projectService.readProjectGuidelines(project.path);
// Process each workitem
const processedWorkitems = [];
const processedWorkitems: ProcessedWorkItem[] = [];
for (const workitem of workitems) {
console.log(`ProjectWorkitemsService: Processing workitem: ${workitem.name}`);
const result = await this.processWorkitem(project, projectRepoPath, workitem, projectGuidelines);
processedWorkitems.push({workitem, ...result});
const result: ProcessedWorkItem = await this.processWorkitem(project, projectRepoPath, workitem, projectGuidelines);
processedWorkitems.push(result);
}
// Generate git patch if any files were written
@ -98,13 +96,7 @@ export class ProjectWorkitemsService {
projectRepoPath: string,
workitem: Workitem,
projectGuidelines: string
): Promise<{
success: boolean;
error?: string;
decision?: 'skip' | 'update' | 'create' | 'delete';
filesWritten?: string[],
filesRemoved?: string[],
}> {
): Promise<ProcessedWorkItem> {
try {
// Set the current workitem
console.log(`ProjectWorkitemsService: Processing workitem: ${workitem.name} (Active: ${workitem.isActive})`);
@ -176,7 +168,8 @@ export class ProjectWorkitemsService {
console.log(`ProjectWorkitemsService: Completed processing workitem: ${workitem.name} (Status: ${decision}, Files written: ${result.filesWritten.length})`);
return {
success: true,
decision,
status: decision,
workitem,
filesWritten: result.filesWritten,
filesRemoved: result.filesDeleted,
};
@ -184,6 +177,7 @@ export class ProjectWorkitemsService {
console.error(`Error processing workitem ${workitem.name}:`, error);
return {
success: false,
workitem: workitem,
error: error instanceof Error ? error.message : String(error),
};
}

View File

@ -2,78 +2,73 @@
* Type definitions for the prompts-to-test-spec function
*/
import {Project} from "shared-functions";
/**
* Status of a workitem implementation
*/
export type WorkitemImplementationStatus = 'create' | 'update' | 'delete';
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;
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;
type: 'username-password' | 'token';
username?: string;
password?: string;
token?: string;
}
export interface ProcessResult {
project: Project;
processedWorkitems: {
export interface ProcessedWorkItem {
workitem: Workitem;
success: boolean;
error?: string;
status?: 'skipped' | 'updated' | 'created';
status?: 'create' | 'update' | 'delete' | 'skip';
filesWritten?: string[];
}[];
pullRequestUrl?: string;
error?: string;
gitPatch?: string;
filesRemoved?: string[];
}
export interface ProcessResult {
project: Project;
processedWorkitems: ProcessedWorkItem[];
pullRequestUrl?: string;
error?: string;
gitPatch?: string;
}
/**
* HTTP response format for the API
*/
export interface HttpResponse {
success: boolean;
projectsProcessed: number;
projectsSucceeded: number;
projectsFailed: number;
mainPullRequestUrl?: string;
projects: ProjectSummary[];
error?: string;
success: boolean;
projectsProcessed: number;
projectsSucceeded: number;
projectsFailed: number;
mainPullRequestUrl?: string;
projects: ProjectSummary[];
error?: string;
}
/**
* Summary of a project's processing results
*/
export interface ProjectSummary {
name: string;
success: boolean;
error?: string;
workitemsProcessed: number;
workitemsSkipped: number;
workitemsUpdated: number;
workitemsCreated: number;
filesWritten: number;
pullRequestUrl?: string;
gitPatch?: string;
name: string;
success: boolean;
error?: string;
workitemsProcessed: number;
workitemsSkipped: number;
workitemsUpdated: number;
workitemsCreated: number;
filesWritten: number;
pullRequestUrl?: string;
gitPatch?: string;
}

View File

@ -1,162 +0,0 @@
/**
* Tests for the GeminiService
*/
import { GeminiService } from '../gemini-service';
import { Workitem } from '../../types';
import { VertexAI } from '@google-cloud/vertexai';
// Mock VertexAI
jest.mock('@google-cloud/vertexai', () => {
return {
VertexAI: jest.fn().mockImplementation(() => {
return {
getGenerativeModel: jest.fn().mockImplementation(() => {
return {
generateContent: jest.fn().mockResolvedValue({
response: {
candidates: [
{
content: {
parts: [
{
text: '# Generated PR Description\n\nThis is a test PR description.'
}
]
}
}
]
}
})
};
})
};
})
};
});
describe('GeminiService', () => {
let geminiService: GeminiService;
beforeEach(() => {
jest.clearAllMocks();
geminiService = new GeminiService('test-project-id');
});
describe('constructor', () => {
it('should initialize with default values', () => {
const service = new GeminiService('test-project-id');
expect(VertexAI).toHaveBeenCalledWith({
project: 'test-project-id',
location: 'us-central1'
});
});
it('should initialize with custom values', () => {
const service = new GeminiService('test-project-id', 'europe-west1', 'gemini-1.0-pro');
expect(VertexAI).toHaveBeenCalledWith({
project: 'test-project-id',
location: 'europe-west1'
});
});
it('should throw an error if project ID is not provided', () => {
expect(() => new GeminiService('')).toThrow('Google Cloud Project ID is required');
});
});
describe('generatePullRequestDescription', () => {
it('should generate a PR description', async () => {
const workitems: { workitem: Workitem; success: boolean; error?: string }[] = [
{
workitem: {
name: 'test-workitem',
path: '/path/to/workitem',
title: 'Test Workitem',
description: 'This is a test workitem',
isActive: true
},
success: true
}
];
const result = await geminiService.generatePullRequestDescription(workitems);
expect(result).toBe('# Generated PR Description\n\nThis is a test PR description.');
});
it('should return a mock PR description in dry run mode', async () => {
const dryRunService = new GeminiService('test-project-id', 'us-central1', 'gemini-1.5-pro', true);
const workitems: { workitem: Workitem; success: boolean; error?: string }[] = [
{
workitem: {
name: 'test-workitem',
path: '/path/to/workitem',
title: 'Test Workitem',
description: 'This is a test workitem',
isActive: true
},
success: true
}
];
const result = await dryRunService.generatePullRequestDescription(workitems);
expect(result).toContain('# Automated PR: Update Workitems (DRY RUN)');
expect(result).toContain('All workitems:');
expect(result).toContain('- test-workitem');
expect(result).toContain('*Note: This is a mock PR description generated during dry run.');
});
it('should include git patch in the prompt if provided', async () => {
const workitems: { workitem: Workitem; success: boolean; error?: string }[] = [
{
workitem: {
name: 'test-workitem',
path: '/path/to/workitem',
title: 'Test Workitem',
description: 'This is a test workitem',
isActive: true
},
success: true
}
];
const gitPatch = 'diff --git a/file.txt b/file.txt\nindex 1234..5678 100644\n--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n-old line\n+new line';
const mockGenerateContent = jest.fn().mockResolvedValue({
response: {
candidates: [
{
content: {
parts: [
{
text: '# Generated PR Description\n\nThis is a test PR description with git patch.'
}
]
}
}
]
}
});
const mockGetGenerativeModel = jest.fn().mockReturnValue({
generateContent: mockGenerateContent
});
(VertexAI as jest.Mock).mockImplementationOnce(() => ({
getGenerativeModel: mockGetGenerativeModel
}));
const service = new GeminiService('test-project-id');
const result = await service.generatePullRequestDescription(workitems, gitPatch);
expect(mockGenerateContent).toHaveBeenCalled();
const promptArg = mockGenerateContent.mock.calls[0][0];
expect(promptArg).toContain('Code Changes');
expect(promptArg).toContain('```diff');
expect(promptArg).toContain(gitPatch);
expect(result).toBe('# Generated PR Description\n\nThis is a test PR description with git patch.');
});
});
});

View File

@ -20,7 +20,7 @@ describe('ProjectService', () => {
});
describe('findProjects', () => {
it('should find all projects in the function directory', async () => {
it('should find all projects in the function directory', () => {
// 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';
@ -42,7 +42,7 @@ describe('ProjectService', () => {
});
// Mock readProjectInfo
jest.spyOn(projectService, 'readProjectInfo').mockImplementation(async (projectPath, projectName) => {
jest.spyOn(projectService, 'readProjectInfo').mockImplementation((projectPath, projectName) => {
return {
name: projectName,
path: projectPath,
@ -52,7 +52,7 @@ describe('ProjectService', () => {
};
});
const projects = await projectService.findProjects('prompts', 'function1');
const projects = projectService.findProjects('prompts', 'function1');
expect(projects).toHaveLength(2);
expect(projects[0].name).toBe('project1');
@ -63,24 +63,24 @@ describe('ProjectService', () => {
expect(fs.existsSync).toHaveBeenCalledWith('prompts/function1/project2/INFO.md');
});
it('should return empty array if prompts directory does not exist', async () => {
it('should return empty array if prompts directory does not exist', () => {
// Mock fs.existsSync to return false for prompts directory
(fs.existsSync as jest.Mock).mockReturnValueOnce(false);
const projects = await projectService.findProjects('prompts', 'function1');
const projects = 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 () => {
it('should return empty array if function directory does not exist', () => {
// 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');
const projects = projectService.findProjects('prompts', 'function1');
expect(projects).toHaveLength(0);
expect(fs.existsSync).toHaveBeenCalledWith('prompts');
@ -90,7 +90,7 @@ describe('ProjectService', () => {
});
describe('readProjectInfo', () => {
it('should read project information from INFO.md', async () => {
it('should read project information from INFO.md', () => {
const infoContent = `# Project Name
- [x] Repo host: https://github.com
@ -100,10 +100,13 @@ describe('ProjectService', () => {
- [x] Jira component: project-component
`;
// Mock fs.existsSync to return true for INFO.md
(fs.existsSync as jest.Mock).mockReturnValueOnce(true);
// Mock fs.readFileSync to return INFO.md content
(fs.readFileSync as jest.Mock).mockReturnValueOnce(infoContent);
const project = await projectService.readProjectInfo('path/to/project', 'project');
const project = projectService.readProjectInfo('path/to/project', 'project');
expect(project).toEqual({
name: 'project',
@ -117,17 +120,20 @@ describe('ProjectService', () => {
expect(fs.readFileSync).toHaveBeenCalledWith('path/to/project/INFO.md', 'utf-8');
});
it('should handle project information that does not follow the expected format', async () => {
it('should handle project information that does not follow the expected format', () => {
const infoContent = `# Project Name
This is a project description.
Some other content that doesn't match the expected format.
`;
// Mock fs.existsSync to return true for INFO.md
(fs.existsSync as jest.Mock).mockReturnValueOnce(true);
// 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');
const project = projectService.readProjectInfo('path/to/project', 'project');
expect(project).toEqual({
name: 'project',
@ -140,10 +146,40 @@ Some other content that doesn't match the expected format.
});
expect(fs.readFileSync).toHaveBeenCalledWith('path/to/project/INFO.md', 'utf-8');
});
it('should throw an error if INFO.md file does not exist', () => {
// Mock fs.existsSync to return false for INFO.md
(fs.existsSync as jest.Mock).mockReturnValueOnce(false);
expect(() => {
projectService.readProjectInfo('path/to/project', 'project');
}).toThrow('INFO.md file not found for project project at path/to/project/INFO.md');
expect(fs.existsSync).toHaveBeenCalledWith('path/to/project/INFO.md');
expect(fs.readFileSync).not.toHaveBeenCalled();
});
it('should throw an error if INFO.md file cannot be read', () => {
// Mock fs.existsSync to return true for INFO.md
(fs.existsSync as jest.Mock).mockReturnValueOnce(true);
// Mock fs.readFileSync to throw an error
const error = new Error('File read error');
(fs.readFileSync as jest.Mock).mockImplementationOnce(() => {
throw error;
});
expect(() => {
projectService.readProjectInfo('path/to/project', 'project');
}).toThrow(`Failed to read INFO.md for project project: ${error.message}`);
expect(fs.existsSync).toHaveBeenCalledWith('path/to/project/INFO.md');
expect(fs.readFileSync).toHaveBeenCalledWith('path/to/project/INFO.md', 'utf-8');
});
});
describe('readProjectGuidelines', () => {
it('should read AI guidelines for a project', async () => {
it('should read AI guidelines for a project', () => {
const guidelinesContent = '## Guidelines\n\nThese are the guidelines.';
// Mock fs.existsSync to return true for AI.md
@ -152,18 +188,18 @@ Some other content that doesn't match the expected format.
// Mock fs.readFileSync to return guidelines content
(fs.readFileSync as jest.Mock).mockReturnValueOnce(guidelinesContent);
const guidelines = await projectService.readProjectGuidelines('path/to/project');
const guidelines = 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 () => {
it('should return empty string if AI.md does not exist', () => {
// Mock fs.existsSync to return false for AI.md
(fs.existsSync as jest.Mock).mockReturnValueOnce(false);
const guidelines = await projectService.readProjectGuidelines('path/to/project');
const guidelines = projectService.readProjectGuidelines('path/to/project');
expect(guidelines).toBe('');
expect(fs.existsSync).toHaveBeenCalledWith('path/to/project/AI.md');

View File

@ -263,17 +263,17 @@ export class GeminiFileSystemService {
/**
* Search for a string in files
* @param rootPath Root path to search in
* @param searchString String to search for
* @param filePattern Optional file pattern to limit the search (e.g., "*.ts", "src/*.java")
* @returns Array of matches with file paths and line numbers
* @throws Error if search string is not provided
*/
grepFiles(rootPath: string, searchString: string, filePattern?: string): Array<{
file: string,
line: number,
content: string
}> {
console.log(`Searching for "${searchString}" in files${filePattern ? ` matching ${filePattern}` : ''}`);
if (!searchString) {
throw new Error('Search string is required');
}
@ -296,7 +296,7 @@ export class GeminiFileSystemService {
}
}
} catch (error) {
console.error(`Error searching in file ${filePath}:`, error);
// Silently ignore file read errors
}
};
@ -322,14 +322,13 @@ export class GeminiFileSystemService {
}
}
} catch (error) {
console.error(`Error searching in directory ${dirPath}:`, error);
// Silently ignore directory read errors
}
};
// Start the search from the root path
searchInDirectory(rootPath, rootPath);
console.log(`Found ${results.length} matches for "${searchString}"`);
return results;
}
@ -432,13 +431,6 @@ After you have implemented the workitem using function calls, use the makeDecisi
// Process the streaming response
for await (const item of streamingResp.stream) {
// Add debug logging for each item in the model stream
console.log(`[DEBUG] Processing stream item`);
// Avoid stringifying the entire item which can be too complex
if (item.candidates && item.candidates.length > 0) {
console.log(`[DEBUG] Item has ${item.candidates.length} candidates`);
}
// Check if there's a function call in any part of the response
let functionCall = null;
let textContent = '';
@ -447,16 +439,13 @@ After you have implemented the workitem using function calls, use the makeDecisi
for (const part of item.candidates?.[0]?.content?.parts || []) {
if (part.functionCall) {
functionCall = part.functionCall;
console.log(`[DEBUG] Function call detected in stream: ${functionCall.name}`);
break;
} else if (part.text) {
textContent += part.text;
console.log(`[DEBUG] Text content detected in stream: ${textContent.substring(0, 100)}${textContent.length > 100 ? '...' : ''}`);
}
}
if (functionCall) {
console.log(`Function call detected: ${functionCall.name}`);
pendingFunctionCalls.push(functionCall);
} else if (textContent) {
// If there's text, append it to the final response
@ -467,8 +456,6 @@ After you have implemented the workitem using function calls, use the makeDecisi
// Process any function calls that were detected
if (pendingFunctionCalls.length > 0) {
console.log(`Processing ${pendingFunctionCalls.length} function calls from streaming response`);
let currentRequest: GenerateContentRequest = request;
// Process each function call
@ -477,8 +464,6 @@ After you have implemented the workitem using function calls, use the makeDecisi
const functionArgs = (typeof functionCall.args === 'string' ?
JSON.parse(functionCall.args) : functionCall.args) as FunctionArgs;
console.log(`Executing function: ${functionName} with args:`, functionArgs);
let functionResponse;
try {
// Execute the function
@ -513,7 +498,6 @@ After you have implemented the workitem using function calls, use the makeDecisi
reason: functionArgs.reason!
};
functionResponse = `Decision recorded: ${functionArgs.decision} - ${functionArgs.reason}`;
console.log(`Model decision: ${functionArgs.decision} - ${functionArgs.reason}`);
break;
default:
throw new Error(`Unknown function: ${functionName}`);
@ -573,9 +557,6 @@ After you have implemented the workitem using function calls, use the makeDecisi
}
}
console.log(`Model stream processing completed`);
console.log(`Files written: ${filesWritten.length}, Files deleted: ${filesDeleted.length}`);
// If no explicit decision was made using the makeDecision function, try to parse it from the text
if (!decision) {
try {
@ -583,10 +564,9 @@ After you have implemented the workitem using function calls, use the makeDecisi
const jsonMatch = finalResponse.match(/\{[\s\S]*"decision"[\s\S]*\}/);
if (jsonMatch) {
decision = JSON.parse(jsonMatch[0]) as ModelResponse;
console.log(`Parsed decision from text: ${decision.decision}, reason: ${decision.reason}`);
}
} catch (error) {
console.error(`Error parsing decision from text: ${error}`);
console.error(`Error parsing JSON decision:`, error);
}
}
@ -602,7 +582,7 @@ After you have implemented the workitem using function calls, use the makeDecisi
/**
* Create the next request with function call and response
* @param currentRequest Current request
* @param functionCall Function call
* @param functionCall Function call object
* @param functionResponseObj Function response object
* @param isError Whether the response is an error
* @returns Next request
@ -654,21 +634,13 @@ After you have implemented the workitem using function calls, use the makeDecisi
let functionCall = null;
for await (const nextItem of nextStreamingResp.stream) {
console.log(`[DEBUG] Processing next stream item${isAfterError ? ' after error' : ''}`);
// Avoid stringifying the entire item which can be too complex
if (nextItem.candidates && nextItem.candidates.length > 0) {
console.log(`[DEBUG] Next item${isAfterError ? ' after error' : ''} has ${nextItem.candidates.length} candidates`);
}
// Iterate over every part in the response
for (const part of nextItem.candidates?.[0]?.content?.parts || []) {
if (part.functionCall) {
functionCall = part.functionCall;
console.log(`[DEBUG] Function call detected in next stream${isAfterError ? ' after error' : ''}: ${functionCall.name}`);
break;
} else if (part.text) {
textContent += part.text;
console.log(`[DEBUG] Text content detected in next stream${isAfterError ? ' after error' : ''}: ${textContent.substring(0, 100)}${textContent.length > 100 ? '...' : ''}`);
}
}
}

View File

@ -1,11 +1,7 @@
/**
* Service for handling Gemini API operations
*/
import {
GenerateContentCandidate,
VertexAI
} from '@google-cloud/vertexai';
import { Workitem } from '../types';
import {VertexAI} from '@google-cloud/vertexai';
export class GeminiService {
private vertexAI: VertexAI;
@ -58,25 +54,9 @@ export class GeminiService {
* const prDescription = await geminiService.generatePullRequestDescription(processedWorkitems, gitPatch);
*/
async generatePullRequestDescription(
processedWorkitems: { workitem: Workitem; success: boolean; error?: string }[],
description: string,
gitPatch?: string
): Promise<string> {
// Prepare workitem data for the prompt
const all: string[] = [];
for (const item of processedWorkitems) {
const {workitem, success, error} = item;
// Add all workitems to the all list regardless of status
all.push(`- ${workitem.name}`);
}
// Create a structured summary of changes
let workitemSummary = '';
if (all.length > 0) {
workitemSummary += 'All workitems:\n' + all.join('\n') + '\n\n';
}
// If dry run is enabled, return a mock PR description
if (this.dryRunSkipGemini) {
@ -87,7 +67,7 @@ This pull request was automatically generated in dry run mode.
## Changes Summary
${workitemSummary}
${description}
*Note: This is a mock PR description generated during dry run. No actual Gemini API call was made.*`;
}
@ -113,7 +93,7 @@ ${gitPatch}
You are tasked with creating a pull request description for changes to test specifications.
The following is a summary of the changes made:
${workitemSummary}
${description}
${gitPatch && gitPatch !== "No changes detected." ? gitPatchSection : ''}
Create a clear, professional pull request description that:

View File

@ -1,5 +1,7 @@
/**
* Service for handling project operations
* This service provides functionality for finding projects, reading project information,
* and reading project guidelines.
*/
import * as fs from 'fs';
import * as path from 'path';
@ -12,7 +14,7 @@ export class ProjectService {
* @param functionName Name of the function to find projects for
* @returns Array of projects
*/
async findProjects(promptsDir: string, functionName: string): Promise<Project[]> {
findProjects(promptsDir: string, functionName: string): Project[] {
const projects: Project[] = [];
// Check if prompts directory exists
@ -41,7 +43,7 @@ export class ProjectService {
}
// Read project info
const project = await this.readProjectInfo(projectPath, dir.name);
const project = this.readProjectInfo(projectPath, dir.name);
projects.push(project);
}
@ -53,10 +55,32 @@ export class ProjectService {
* @param projectPath Path to the project directory
* @param projectName Name of the project
* @returns Project information
* @throws Error if INFO.md file doesn't exist or can't be read
*
* The INFO.md file is expected to have the following format:
* ```
* # 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
* ```
*/
async readProjectInfo(projectPath: string, projectName: string): Promise<Project> {
readProjectInfo(projectPath: string, projectName: string): Project {
const infoPath = path.join(projectPath, 'INFO.md');
const infoContent = fs.readFileSync(infoPath, 'utf-8');
if (!fs.existsSync(infoPath)) {
throw new Error(`INFO.md file not found for project ${projectName} at ${infoPath}`);
}
let infoContent: string;
try {
infoContent = fs.readFileSync(infoPath, 'utf-8');
} catch (error) {
throw new Error(`Failed to read INFO.md for project ${projectName}: ${error instanceof Error ? error.message : String(error)}`);
}
// Parse INFO.md content
const repoHostMatch = infoContent.match(/- \[[ x]\] Repo host: (.*)/);
@ -81,9 +105,9 @@ export class ProjectService {
/**
* Read AI guidelines for a project
* @param projectPath Path to the project directory
* @returns AI guidelines content
* @returns AI guidelines content or empty string if AI.md doesn't exist
*/
async readProjectGuidelines(projectPath: string): Promise<string> {
readProjectGuidelines(projectPath: string): string {
const aiPath = path.join(projectPath, 'AI.md');
if (!fs.existsSync(aiPath)) {

View File

@ -72,7 +72,6 @@ export class RepositoryService {
// Checkout the target branch if specified
if (project.targetBranch) {
console.log(`Checking out target branch: ${project.targetBranch}`);
await this.checkoutBranch(projectRepoDir, project.targetBranch);
}
@ -147,14 +146,13 @@ export class RepositoryService {
* Checkout an existing branch in a repository
* @param repoDir Path to the repository
* @param branchName Name of the branch to checkout
* @throws Error if checkout fails
*/
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)}`);
}
}

View File

@ -3,29 +3,18 @@
*/
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;
name: string;
path: string;
repoHost?: string;
repoUrl?: string;
jiraComponent?: string;
targetBranch?: string;
aiGuidelines?: string;
}
export interface RepoCredentials {
type: 'username-password' | 'token';
username?: string;
password?: string;
token?: string;
type: 'username-password' | 'token';
username?: string;
password?: string;
token?: string;
}

View File

@ -7,13 +7,3 @@ The nitro-back backend should have a /test endpoint implemented returning the js
- [ ] 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.