diff --git a/src/functions/prompts-to-test-spec/src/services/gemini-service.ts b/src/functions/prompts-to-test-spec/src/services/gemini-service.ts deleted file mode 100644 index 8db23f9..0000000 --- a/src/functions/prompts-to-test-spec/src/services/gemini-service.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Service for handling Gemini API operations - */ -import { - FunctionDeclarationSchemaType, - GenerateContentCandidate, - GenerateContentRequest, - Tool, - VertexAI -} from '@google-cloud/vertexai'; -import {Workitem} from '../types'; -import {DRY_RUN_SKIP_GEMINI, GEMINI_MODEL, GOOGLE_CLOUD_LOCATION, GOOGLE_CLOUD_PROJECT_ID} from '../config'; -import {GeminiProjectProcessor} from './gemini-project-processor'; - - -/** - * Interface for the model response format - */ -interface ModelResponse { - decision: 'create' | 'update' | 'delete' | 'skip'; - reason: string; -} - -/** - * Interface for the result returned by generateFeatureFile - */ -interface GenerateFeatureFileResult { - text: string; - decision?: ModelResponse; -} - -/** - * Interface for function arguments - */ -interface FunctionArgs { - filePath?: string; - content?: string; - dirPath?: string; - searchString?: string; - filePattern?: string; -} - -export class GeminiService { - private vertexAI: VertexAI; - private model: string; - private projectId: string; - private location: string; - private fileOperationTools: Tool[]; - - /** - * Create a new GeminiService instance - * @param projectId Google Cloud project ID (defaults to GOOGLE_CLOUD_PROJECT_ID from config) - * @param location Google Cloud location (defaults to GOOGLE_CLOUD_LOCATION from config) - * @param model Gemini model to use (defaults to GEMINI_MODEL from config) - */ - constructor(projectId?: string, location?: string, model?: string) { - this.projectId = projectId || GOOGLE_CLOUD_PROJECT_ID; - this.location = location || GOOGLE_CLOUD_LOCATION; - this.model = model || GEMINI_MODEL; - - if (!this.projectId) { - throw new Error('Google Cloud Project ID is required'); - } - - // Initialize VertexAI with default authentication - // Authentication is handled by the Google Auth library, which supports: - // 1. API keys via GOOGLE_API_KEY environment variable - // 2. Service accounts via GOOGLE_APPLICATION_CREDENTIALS environment variable - // 3. Application Default Credentials - // See: https://cloud.google.com/vertex-ai/docs/authentication - this.vertexAI = new VertexAI({ - project: this.projectId, - location: this.location, - }); - - // Define file operation functions - this.fileOperationTools = [ - { - function_declarations: [ - { - name: "getFileContent", - description: "Get the content of a file in the project repository", - parameters: { - type: FunctionDeclarationSchemaType.OBJECT, - properties: { - filePath: { - type: FunctionDeclarationSchemaType.STRING, - description: "Path to the file relative to the project repository root" - } - }, - required: ["filePath"] - } - }, - { - name: "writeFileContent", - description: "Write content to a file in the project repository", - parameters: { - type: FunctionDeclarationSchemaType.OBJECT, - properties: { - filePath: { - type: FunctionDeclarationSchemaType.STRING, - description: "Path to the file relative to the project repository root" - }, - content: { - type: FunctionDeclarationSchemaType.STRING, - description: "Content to write to the file" - } - }, - required: ["filePath", "content"] - } - }, - { - name: "fileExists", - description: "Check if a file exists in the project repository", - parameters: { - type: FunctionDeclarationSchemaType.OBJECT, - properties: { - filePath: { - type: FunctionDeclarationSchemaType.STRING, - description: "Path to the file relative to the project repository root" - } - }, - required: ["filePath"] - } - }, - { - name: "listFiles", - description: "List files in a directory in the project repository", - parameters: { - type: FunctionDeclarationSchemaType.OBJECT, - properties: { - dirPath: { - type: FunctionDeclarationSchemaType.STRING, - description: "Path to the directory relative to the project repository root" - } - }, - required: ["dirPath"] - } - }, - { - name: "grepFiles", - description: "Search for a string in project files", - parameters: { - type: FunctionDeclarationSchemaType.OBJECT, - properties: { - searchString: { - type: FunctionDeclarationSchemaType.STRING, - description: "String to search for in project files" - }, - filePattern: { - type: FunctionDeclarationSchemaType.STRING, - description: "Optional file pattern to limit the search (e.g., '*.ts', 'src/*.java')" - } - }, - required: ["searchString"] - } - }, - { - name: "deleteFile", - description: "Delete a file from the project repository", - parameters: { - type: FunctionDeclarationSchemaType.OBJECT, - properties: { - filePath: { - type: FunctionDeclarationSchemaType.STRING, - description: "Path to the file relative to the project repository root" - } - }, - required: ["filePath"] - } - } - ] - } - ]; - } - - /** - * Generate a pull request description using Gemini API - * @param processedWorkitems List of processed workitems with their status - * @param gitPatch Optional git patch showing code changes - * @returns Generated pull request description in markdown format - * @example - * const geminiService = new GeminiService(); - * const prDescription = await geminiService.generatePullRequestDescription(processedWorkitems, gitPatch); - */ - async generatePullRequestDescription( - processedWorkitems: { workitem: Workitem; success: boolean; error?: string }[], - gitPatch?: string - ): Promise { - // 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 (DRY_RUN_SKIP_GEMINI) { - console.log(`[DRY RUN] Skipping Gemini API call for generating pull request description`); - return `# Automated PR: Update Workitems (DRY RUN) - -This pull request was automatically generated by the prompts-to-test-spec function in dry run mode. - -## Changes Summary - -${workitemSummary} - -*Note: This is a mock PR description generated during dry run. No actual Gemini API call was made.*`; - } - - const generativeModel = this.vertexAI.getGenerativeModel({ - model: this.model, - }); - - // Prepare the git patch section if available - let gitPatchSection = ''; - if (gitPatch && gitPatch !== "No changes detected.") { - gitPatchSection = ` -## Code Changes -The following code changes were made in this pull request: - -\`\`\`diff -${gitPatch} -\`\`\` -`; - } - - const prompt = ` -You are tasked with creating a pull request description for changes to test specifications. - -The following is a summary of the changes made: -${workitemSummary} -${gitPatch && gitPatch !== "No changes detected." ? gitPatchSection : ''} - -Create a clear, professional pull request description that: -1. Explains that this PR was automatically generated by the prompts-to-test-spec function -2. Summarizes the changes (added, updated, deleted, and failed workitems) -3. If code changes are provided, analyze them and include a summary of the key changes -4. Uses markdown formatting for better readability -5. Keeps the description concise but informative - -The pull request description should be ready to use without further editing. -`; - - const result = await generativeModel.generateContent(prompt); - - const response = await result.response; - const generatedText = response.candidates[0]?.content?.parts[0]?.text || ''; - - return generatedText; - } -} diff --git a/src/functions/prompts-to-test-spec/src/services/model-stream-service.ts b/src/functions/prompts-to-test-spec/src/services/model-stream-service.ts index 10eccf1..35ea1ec 100644 --- a/src/functions/prompts-to-test-spec/src/services/model-stream-service.ts +++ b/src/functions/prompts-to-test-spec/src/services/model-stream-service.ts @@ -8,9 +8,9 @@ import { Tool, VertexAI } from '@google-cloud/vertexai'; -import { Workitem } from '../types'; -import { DRY_RUN_SKIP_GEMINI, GEMINI_MODEL, GOOGLE_CLOUD_LOCATION, GOOGLE_CLOUD_PROJECT_ID } from '../config'; -import { GeminiProjectProcessor } from './gemini-project-processor'; +import {Workitem} from '../types'; +import {DRY_RUN_SKIP_GEMINI, GEMINI_MODEL, GOOGLE_CLOUD_LOCATION, GOOGLE_CLOUD_PROJECT_ID} from '../config'; +import {GeminiProjectProcessor} from './gemini-project-processor'; /** * Interface for the model response format @@ -238,18 +238,7 @@ export class ModelStreamService { // If dry run is enabled, return a mock result if (this.dryRun || DRY_RUN_SKIP_GEMINI) { console.log(`[DRY RUN] Skipping Gemini API call for processing workitem ${this.workitem.name}`); - const mockText = `# Generated by prompts-to-test-spec on ${currentDate} (DRY RUN) -# Source: ${this.workitem.name} - -Feature: ${this.workitem.name} (DRY RUN) - This is a mock feature file generated during dry run. - No actual Gemini API call was made. - - Scenario: Mock scenario - Given a dry run is enabled - When the feature file is generated - Then a mock feature file is returned -`; + const mockText = `# Generated by prompts-to-test-spec on ${currentDate} (DRY RUN)`; return { text: mockText, decision: { diff --git a/src/functions/prompts-to-test-spec/src/services/pull-request-service.ts b/src/functions/prompts-to-test-spec/src/services/pull-request-service.ts index 1c69c93..8690569 100644 --- a/src/functions/prompts-to-test-spec/src/services/pull-request-service.ts +++ b/src/functions/prompts-to-test-spec/src/services/pull-request-service.ts @@ -1,8 +1,8 @@ /** * Service for handling pull request operations */ -import {PullRequestService as SharedPullRequestService, Project, RepoCredentials, Workitem} from 'shared-functions'; -import {GeminiService} from './gemini-service'; +import {PullRequestService as SharedPullRequestService, Project, RepoCredentials, Workitem, GeminiService} from 'shared-functions'; +import {GOOGLE_CLOUD_PROJECT_ID, GOOGLE_CLOUD_LOCATION, GEMINI_MODEL, DRY_RUN_SKIP_GEMINI} from '../config'; export class PullRequestService { private sharedPullRequestService: SharedPullRequestService; @@ -10,7 +10,12 @@ export class PullRequestService { constructor() { this.sharedPullRequestService = new SharedPullRequestService(); - this.geminiService = new GeminiService(); + this.geminiService = new GeminiService( + GOOGLE_CLOUD_PROJECT_ID, + GOOGLE_CLOUD_LOCATION, + GEMINI_MODEL, + DRY_RUN_SKIP_GEMINI + ); } /** diff --git a/src/functions/shared/package-lock.json b/src/functions/shared/package-lock.json index aea99ff..4f67923 100644 --- a/src/functions/shared/package-lock.json +++ b/src/functions/shared/package-lock.json @@ -8,6 +8,7 @@ "name": "shared-functions", "version": "1.0.0", "dependencies": { + "@google-cloud/vertexai": "^0.5.0", "axios": "^1.6.0", "simple-git": "^3.20.0" }, @@ -596,6 +597,18 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@google-cloud/vertexai": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@google-cloud/vertexai/-/vertexai-0.5.0.tgz", + "integrity": "sha512-qIFHYTXA5UCLdm9JG+Xf1suomCXxRqa1PKdYjqXuhZsCm8mn37Rb0Tf8djlhDzuRVWyWoQTmsWpsk28ZTmbqJg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1333,6 +1346,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1573,6 +1595,35 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", + "integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1653,6 +1704,12 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1985,6 +2042,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -2321,6 +2387,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2527,6 +2599,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2658,6 +2760,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2684,6 +2812,19 @@ "dev": true, "license": "MIT" }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2740,6 +2881,19 @@ "dev": true, "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -2916,7 +3070,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3664,6 +3817,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3705,6 +3867,27 @@ "node": ">=6" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3929,6 +4112,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4459,6 +4662,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -4725,6 +4948,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-jest": { "version": "29.3.4", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.4.tgz", @@ -4899,6 +5128,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -4924,6 +5166,22 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/src/functions/shared/package.json b/src/functions/shared/package.json index 8c6224c..2954899 100644 --- a/src/functions/shared/package.json +++ b/src/functions/shared/package.json @@ -10,6 +10,7 @@ "lint": "eslint src/**/*.ts" }, "dependencies": { + "@google-cloud/vertexai": "^0.5.0", "axios": "^1.6.0", "simple-git": "^3.20.0" }, diff --git a/src/functions/shared/src/index.ts b/src/functions/shared/src/index.ts index f8f615d..db66572 100644 --- a/src/functions/shared/src/index.ts +++ b/src/functions/shared/src/index.ts @@ -9,3 +9,4 @@ export * from './types'; export { ProjectService } from './services/project-service'; export { RepositoryService } from './services/repository-service'; export { PullRequestService } from './services/pull-request-service'; +export { GeminiService } from './services/gemini-service'; diff --git a/src/functions/shared/src/services/__tests__/gemini-service.test.ts b/src/functions/shared/src/services/__tests__/gemini-service.test.ts new file mode 100644 index 0000000..aa8620b --- /dev/null +++ b/src/functions/shared/src/services/__tests__/gemini-service.test.ts @@ -0,0 +1,162 @@ +/** + * 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.'); + }); + }); +}); diff --git a/src/functions/shared/src/services/gemini-service.ts b/src/functions/shared/src/services/gemini-service.ts new file mode 100644 index 0000000..15a5f79 --- /dev/null +++ b/src/functions/shared/src/services/gemini-service.ts @@ -0,0 +1,136 @@ +/** + * Service for handling Gemini API operations + */ +import { + GenerateContentCandidate, + VertexAI +} from '@google-cloud/vertexai'; +import { Workitem } from '../types'; + +export class GeminiService { + private vertexAI: VertexAI; + private model: string; + private projectId: string; + private location: string; + private dryRunSkipGemini: boolean; + + /** + * Create a new GeminiService instance + * @param projectId Google Cloud project ID + * @param location Google Cloud location + * @param model Gemini model to use + * @param dryRunSkipGemini Whether to skip Gemini API calls in dry run mode + */ + constructor( + projectId: string, + location: string = 'us-central1', + model: string = 'gemini-1.5-pro', + dryRunSkipGemini: boolean = false + ) { + this.projectId = projectId; + this.location = location; + this.model = model; + this.dryRunSkipGemini = dryRunSkipGemini; + + if (!this.projectId) { + throw new Error('Google Cloud Project ID is required'); + } + + // Initialize VertexAI with default authentication + // Authentication is handled by the Google Auth library, which supports: + // 1. API keys via GOOGLE_API_KEY environment variable + // 2. Service accounts via GOOGLE_APPLICATION_CREDENTIALS environment variable + // 3. Application Default Credentials + // See: https://cloud.google.com/vertex-ai/docs/authentication + this.vertexAI = new VertexAI({ + project: this.projectId, + location: this.location, + }); + } + + /** + * Generate a pull request description using Gemini API + * @param processedWorkitems List of processed workitems with their status + * @param gitPatch Optional git patch showing code changes + * @returns Generated pull request description in markdown format + * @example + * const geminiService = new GeminiService('my-project-id'); + * const prDescription = await geminiService.generatePullRequestDescription(processedWorkitems, gitPatch); + */ + async generatePullRequestDescription( + processedWorkitems: { workitem: Workitem; success: boolean; error?: string }[], + gitPatch?: string + ): Promise { + // 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) { + console.log(`[DRY RUN] Skipping Gemini API call for generating pull request description`); + return `# Automated PR: Update Workitems (DRY RUN) + +This pull request was automatically generated in dry run mode. + +## Changes Summary + +${workitemSummary} + +*Note: This is a mock PR description generated during dry run. No actual Gemini API call was made.*`; + } + + const generativeModel = this.vertexAI.getGenerativeModel({ + model: this.model, + }); + + // Prepare the git patch section if available + let gitPatchSection = ''; + if (gitPatch && gitPatch !== "No changes detected.") { + gitPatchSection = ` +## Code Changes +The following code changes were made in this pull request: + +\`\`\`diff +${gitPatch} +\`\`\` +`; + } + + const prompt = ` +You are tasked with creating a pull request description for changes to test specifications. + +The following is a summary of the changes made: +${workitemSummary} +${gitPatch && gitPatch !== "No changes detected." ? gitPatchSection : ''} + +Create a clear, professional pull request description that: +1. Explains that this PR was automatically generated +2. Summarizes the changes (added, updated, deleted, and failed workitems) +3. If code changes are provided, analyze them and include a summary of the key changes +4. Uses markdown formatting for better readability +5. Keeps the description concise but informative + +The pull request description should be ready to use without further editing. +`; + + const result = await generativeModel.generateContent(prompt); + + const response = await result.response; + const generatedText = response.candidates[0]?.content?.parts[0]?.text || ''; + + return generatedText; + } +}