From c81fdd02aacc2b7880fea352cb9cbe4cd67b31d9 Mon Sep 17 00:00:00 2001 From: Yasith Rashan Date: Sat, 30 May 2026 19:16:10 +0530 Subject: [PATCH 1/3] Add Code Map support to AI agent flow --- .../src/interfaces/extended-lang-client.ts | 11 +++++++- .../src/core/extended-language-client.ts | 8 ++++++ .../src/features/ai/activator.ts | 16 ++++++++++++ .../src/features/ai/agent/AgentExecutor.ts | 12 +++++++-- .../src/features/ai/agent/index.ts | 25 +++++++++++++++++++ .../src/features/ai/agent/prompts.ts | 6 ++--- .../ai/executors/base/AICommandExecutor.ts | 3 +++ .../features/ai/utils/project/temp-project.ts | 3 ++- 8 files changed, 77 insertions(+), 7 deletions(-) diff --git a/packages/ballerina-core/src/interfaces/extended-lang-client.ts b/packages/ballerina-core/src/interfaces/extended-lang-client.ts index 49b808f6b5c..fa3812c2e16 100644 --- a/packages/ballerina-core/src/interfaces/extended-lang-client.ts +++ b/packages/ballerina-core/src/interfaces/extended-lang-client.ts @@ -613,7 +613,7 @@ export interface ExecutorPositions { executorPositions?: ExecutorPosition[]; } -// Test Manager related interfaces +// Test Manager related interfaces export interface TestsDiscoveryRequest { projectPath: string; @@ -2030,6 +2030,14 @@ export interface ProjectArtifacts { artifacts: Artifacts; } +export interface CodeMapRequest { + projectPath: string; +} + +export interface CodeMapResponse { + content: string; +} + export interface ProjectInfoRequest { projectPath: string; } @@ -2142,6 +2150,7 @@ export interface ExtendedLangClientInterface extends BIInterface { updateStatusBar(): void; getDidOpenParams(): DidOpenParams; getProjectArtifacts(params: ProjectArtifactsRequest): Promise; + getCodeMap(params: CodeMapRequest): Promise; getProjectInfo(params: ProjectInfoRequest): Promise; getSimpleTypeOfExpression(params: GetSimpleTypeOfExpressionRequest): Promise; openConfigToml(params: OpenConfigTomlRequest): Promise; diff --git a/packages/ballerina-extension/src/core/extended-language-client.ts b/packages/ballerina-extension/src/core/extended-language-client.ts index 8cb11bed46c..2bca2700ad7 100644 --- a/packages/ballerina-extension/src/core/extended-language-client.ts +++ b/packages/ballerina-extension/src/core/extended-language-client.ts @@ -275,6 +275,8 @@ import { ClausePositionRequest, SemanticDiffRequest, SemanticDiffResponse, + CodeMapRequest, + CodeMapResponse, ConvertExpressionRequest, ConvertExpressionResponse, IntrospectDatabaseRequest, @@ -491,6 +493,7 @@ enum EXTENDED_APIS { COPILOT_ALL_LIBRARIES = 'copilotLibraryManager/getLibrariesList', COPILOT_FILTER_LIBRARIES = 'copilotLibraryManager/getFilteredLibraries', COPILOT_SEARCH_LIBRARIES = 'copilotLibraryManager/getLibrariesBySearch', + COPILOT_GET_CODE_MAP = 'designModelService/codeMap', GET_MIGRATION_TOOLS = 'projectService/getMigrationTools', TIBCO_TO_BI = 'projectService/importTibco', MULE_TO_BI = 'projectService/importMule', @@ -1502,6 +1505,11 @@ export class ExtendedLangClient extends LanguageClient implements ExtendedLangCl return this.sendRequest(EXTENDED_APIS.BI_GET_SEMANTIC_DIFF, params); } + async getCodeMap(params: CodeMapRequest): Promise { + return this.sendRequest(EXTENDED_APIS.COPILOT_GET_CODE_MAP, params); + } + + // <------------ BI APIS END ---------------> diff --git a/packages/ballerina-extension/src/features/ai/activator.ts b/packages/ballerina-extension/src/features/ai/activator.ts index c7dabc89a66..3491042108a 100644 --- a/packages/ballerina-extension/src/features/ai/activator.ts +++ b/packages/ballerina-extension/src/features/ai/activator.ts @@ -86,6 +86,21 @@ export function activateAIFeatures(ballerinaExternalInstance: BallerinaExtension workspacePath: params.projectPath }; + // Fetch Code Map for the test project + let codeMapMarkdown: string | undefined; + try { + const codeMapResponse = await langClient.getCodeMap({ projectPath: params.projectPath }); + const markdown = codeMapResponse?.content; + if (markdown) { + codeMapMarkdown = markdown; + console.log(`[Test Mode] Code Map fetched for project ${params.projectPath} (${markdown.length} chars)`); + } else { + console.log(`[Test Mode] Code Map response was empty for project ${params.projectPath}`); + } + } catch (err) { + console.warn(`[Test Mode] Failed to fetch Code Map, continuing without it:`, err); + } + // Create config using new AICommandConfig pattern const config: AICommandConfig = { executionContext: ctx, @@ -95,6 +110,7 @@ export function activateAIFeatures(ballerinaExternalInstance: BallerinaExtension params, // No chat storage in test mode chatStorage: undefined, + codeMapMarkdown, // Immediate cleanup (AI_TEST_ENV prevents actual deletion) lifecycle: { cleanupStrategy: 'immediate' diff --git a/packages/ballerina-extension/src/features/ai/agent/AgentExecutor.ts b/packages/ballerina-extension/src/features/ai/agent/AgentExecutor.ts index a8da19847ea..cfb70aaca6a 100644 --- a/packages/ballerina-extension/src/features/ai/agent/AgentExecutor.ts +++ b/packages/ballerina-extension/src/features/ai/agent/AgentExecutor.ts @@ -270,7 +270,15 @@ export class AgentExecutor extends AICommandExecutor { const loginMethod = await getLoginMethod(); const model = await getAnthropicClient(ANTHROPIC_SONNET_4); - const userMessageContent = getUserPrompt(params, tempProjectPath, projects); + // Code Map is fetched at query submission time in index.ts generateAgent + const codeMapMarkdown = this.config.codeMapMarkdown; + if (codeMapMarkdown) { + console.log(`[AgentExecutor] Code Map included in LLM prompt (${codeMapMarkdown.length} chars)`); + } else { + console.log(`[AgentExecutor] No Code Map available — sending prompt without Code Map`); + } + + const userMessageContent = getUserPrompt(params, tempProjectPath, projects, codeMapMarkdown); // Estimate fixed overhead (system prompt + codebase) to decide if compaction is viable const systemPromptText = getSystemPrompt(projects, params.operationType); @@ -296,7 +304,7 @@ export class AgentExecutor extends AICommandExecutor { // 5. Build LLM messages with history const historyMessages = populateHistoryForAgent(chatHistory); const cacheOptions = await getProviderCacheControl(); - + const allMessages: ModelMessage[] = [ { role: "system", diff --git a/packages/ballerina-extension/src/features/ai/agent/index.ts b/packages/ballerina-extension/src/features/ai/agent/index.ts index 18fe1dbb6a4..4ef37515116 100644 --- a/packages/ballerina-extension/src/features/ai/agent/index.ts +++ b/packages/ballerina-extension/src/features/ai/agent/index.ts @@ -117,6 +117,31 @@ export async function generateAgent(params: GenerateAgentCodeRequest): Promise`; } -export function getUserPrompt(params: GenerateAgentCodeRequest, tempProjectPath: string, projects: ProjectSource[]) { +export function getUserPrompt(params: GenerateAgentCodeRequest, tempProjectPath: string, projects: ProjectSource[], codeMapMarkdown?: string) { const content = []; content.push({ diff --git a/packages/ballerina-extension/src/features/ai/executors/base/AICommandExecutor.ts b/packages/ballerina-extension/src/features/ai/executors/base/AICommandExecutor.ts index f8d3b29a531..63b5ce803c7 100644 --- a/packages/ballerina-extension/src/features/ai/executors/base/AICommandExecutor.ts +++ b/packages/ballerina-extension/src/features/ai/executors/base/AICommandExecutor.ts @@ -102,6 +102,9 @@ export interface AICommandConfig { * summaries to `.ballerina-ai-migration/debug.log` via this logger. */ debugLogger?: MigrationDebugLogger; + + /** Code Map markdown captured at query submission time */ + codeMapMarkdown?: string; } /** diff --git a/packages/ballerina-extension/src/features/ai/utils/project/temp-project.ts b/packages/ballerina-extension/src/features/ai/utils/project/temp-project.ts index 886ec231c9f..b9e445b5e76 100644 --- a/packages/ballerina-extension/src/features/ai/utils/project/temp-project.ts +++ b/packages/ballerina-extension/src/features/ai/utils/project/temp-project.ts @@ -187,7 +187,8 @@ export async function getProjectSource(requestType: OperationType, ctx: Executio return [convertToProjectSource(project, "", true)]; } - const packagePaths = StateMachine.context().projectInfo?.children.map(child => child.projectPath); + const packagePaths = StateMachine.context().projectInfo?.children?.map(child => child.projectPath) + ?? workspaceTomlValues.workspace.packages; // Load all packages in parallel const projectSources: ProjectSource[] = await Promise.all( packagePaths.map(async (pkgPath) => { From 9c6f615dcd9a1d75ad79a22a82f28c07bc8637ff Mon Sep 17 00:00:00 2001 From: Yasith Rashan Date: Sat, 30 May 2026 20:37:36 +0530 Subject: [PATCH 2/3] Add context retrieval evaluation --- .../src/features/ai/agent/prompts.ts | 43 ++- .../features/ai/agent/tools/text-editor.ts | 47 ++- .../result-management/report-generator.ts | 16 + .../result-management/result-conversion.ts | 1 + .../result-management/result-persistence.ts | 2 + .../test/ai/evals/code/test-cases.ts | 141 +++++++- .../test/ai/evals/code/types/result-types.ts | 4 +- .../test/ai/evals/code/types/test-types.ts | 3 +- .../ai/evals/code/utils/evaluator-utils.ts | 174 ++++++++++ .../ai/evals/code/utils/test-validation.ts | 18 +- .../data/healthcare_sample/Ballerina.toml | 14 + .../test/data/healthcare_sample/Config.toml | 8 + .../encounter_api_config.bal | 108 ++++++ .../test/data/healthcare_sample/mapping.bal | 84 +++++ .../modules/db/persist_client.bal | 175 ++++++++++ .../modules/db/persist_db_config.bal | 30 ++ .../modules/db/persist_types.bal | 84 +++++ .../healthcare_sample/modules/db/script.sql | 30 ++ .../healthcare_sample/patient_api_config.bal | 128 +++++++ .../data/healthcare_sample/persist/model.bal | 60 ++++ .../test/data/healthcare_sample/service.bal | 210 ++++++++++++ .../data/hotel_reservation/Ballerina.toml | 8 + .../test/data/hotel_reservation/service.bal | 48 +++ .../data/hotel_reservation/tests/Config.toml | 5 + .../hotel_reservation/tests/service_test.bal | 15 + .../test/data/hotel_reservation/types.bal | 51 +++ .../test/data/hotel_reservation/utils.bal | 317 ++++++++++++++++++ .../order_management_system/Ballerina.toml | 2 + .../order_service/Ballerina.toml | 13 + .../order_service/configurations.bal | 1 + .../order_service/functions.bal | 100 ++++++ .../order_service/main.bal | 15 + .../order_service/modules/db/db_client.bal | 13 + .../order_service/modules/db/db_config.bal | 5 + .../modules/db/db_operations.bal | 21 ++ .../order_service/modules/db/db_types.bal | 25 ++ .../modules/messaging/kafka_config.bal | 2 + .../modules/messaging/kafka_operations.bal | 14 + .../modules/messaging/kafka_producer.bal | 9 + .../modules/messaging/kafka_types.bal | 25 ++ .../order_service/service.bal | 50 +++ .../order_service/types.bal | 64 ++++ .../order_utils/Ballerina.toml | 5 + .../order_utils/utils.bal | 25 ++ .../Ballerina.toml | 7 + .../salesforce_slack_integration/agents.bal | 0 .../salesforce_slack_integration/config.bal | 20 ++ .../connections.bal | 26 ++ .../data_mappings.bal | 37 ++ .../functions.bal | 215 ++++++++++++ .../salesforce_slack_integration/main.bal | 160 +++++++++ .../salesforce_slack_integration/types.bal | 65 ++++ .../Ballerina.toml | 6 + .../agents.bal | 0 .../config.bal | 20 ++ .../connections.bal | 26 ++ .../data_mappings.bal | 37 ++ .../functions.bal | 215 ++++++++++++ .../main.bal | 160 +++++++++ .../types.bal | 65 ++++ .../Ballerina.toml | 6 + .../agents.bal | 0 .../automation.bal | 0 .../config.bal | 7 + .../connections.bal | 16 + .../data_mappings.bal | 0 .../functions.bal | 0 .../main.bal | 34 ++ .../types.bal | 0 69 files changed, 3288 insertions(+), 47 deletions(-) create mode 100644 packages/ballerina-extension/test/data/healthcare_sample/Ballerina.toml create mode 100644 packages/ballerina-extension/test/data/healthcare_sample/Config.toml create mode 100644 packages/ballerina-extension/test/data/healthcare_sample/encounter_api_config.bal create mode 100644 packages/ballerina-extension/test/data/healthcare_sample/mapping.bal create mode 100644 packages/ballerina-extension/test/data/healthcare_sample/modules/db/persist_client.bal create mode 100644 packages/ballerina-extension/test/data/healthcare_sample/modules/db/persist_db_config.bal create mode 100644 packages/ballerina-extension/test/data/healthcare_sample/modules/db/persist_types.bal create mode 100644 packages/ballerina-extension/test/data/healthcare_sample/modules/db/script.sql create mode 100644 packages/ballerina-extension/test/data/healthcare_sample/patient_api_config.bal create mode 100644 packages/ballerina-extension/test/data/healthcare_sample/persist/model.bal create mode 100644 packages/ballerina-extension/test/data/healthcare_sample/service.bal create mode 100644 packages/ballerina-extension/test/data/hotel_reservation/Ballerina.toml create mode 100644 packages/ballerina-extension/test/data/hotel_reservation/service.bal create mode 100644 packages/ballerina-extension/test/data/hotel_reservation/tests/Config.toml create mode 100644 packages/ballerina-extension/test/data/hotel_reservation/tests/service_test.bal create mode 100644 packages/ballerina-extension/test/data/hotel_reservation/types.bal create mode 100644 packages/ballerina-extension/test/data/hotel_reservation/utils.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/Ballerina.toml create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/Ballerina.toml create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/configurations.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/functions.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/main.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_client.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_config.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_operations.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_types.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_config.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_operations.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_producer.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_types.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/service.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_service/types.bal create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_utils/Ballerina.toml create mode 100644 packages/ballerina-extension/test/data/order_management_system/order_utils/utils.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration/Ballerina.toml create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration/agents.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration/config.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration/connections.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration/data_mappings.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration/functions.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration/main.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration/types.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration_errors/Ballerina.toml create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration_errors/agents.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration_errors/config.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration_errors/connections.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration_errors/data_mappings.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration_errors/functions.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration_errors/main.bal create mode 100644 packages/ballerina-extension/test/data/salesforce_slack_integration_errors/types.bal create mode 100644 packages/ballerina-extension/test/data/shopify_stripe_integration_errors/Ballerina.toml create mode 100644 packages/ballerina-extension/test/data/shopify_stripe_integration_errors/agents.bal create mode 100644 packages/ballerina-extension/test/data/shopify_stripe_integration_errors/automation.bal create mode 100644 packages/ballerina-extension/test/data/shopify_stripe_integration_errors/config.bal create mode 100644 packages/ballerina-extension/test/data/shopify_stripe_integration_errors/connections.bal create mode 100644 packages/ballerina-extension/test/data/shopify_stripe_integration_errors/data_mappings.bal create mode 100644 packages/ballerina-extension/test/data/shopify_stripe_integration_errors/functions.bal create mode 100644 packages/ballerina-extension/test/data/shopify_stripe_integration_errors/main.bal create mode 100644 packages/ballerina-extension/test/data/shopify_stripe_integration_errors/types.bal diff --git a/packages/ballerina-extension/src/features/ai/agent/prompts.ts b/packages/ballerina-extension/src/features/ai/agent/prompts.ts index c0a519026e8..95da1255c5e 100644 --- a/packages/ballerina-extension/src/features/ai/agent/prompts.ts +++ b/packages/ballerina-extension/src/features/ai/agent/prompts.ts @@ -174,14 +174,27 @@ ${getLanglibInstructions()} - Mention types EXPLICITLY in variable declarations and foreach statements. (Avoid var at all costs) - To narrow down a union type(or optional type), always declare a separate variable and then use that variable in the if condition. -## File modifications +# Codebase Exploration +- When the user submits a query, you will receive either **Codebase High Level Overview** or **Complete Structure of the Codebase**. Identify which one you have received before proceeding. +- If you received Codebase High Level Overview, use it as a navigation map to locate the relevant components to the user query, in the codebase, but the actual source must be read separately when needed. +- Codebase High Level Overview lists, for each Ballerina file, all of its components (imports, configurables, variables, types, functions, services, listeners, classes) with their signatures and line ranges, but excludes implementation bodies, test files, and resource files. +- If you receive complete structure of the codebase, it contains the complete source of all .bal files (test and resource files excluded) provided directly in your context. + +## Context Retrieval +- Explore the codebase with ${FILE_READ_TOOL_NAME}, and keep exploring until you have all the context required to answer confidently. + +### Rules for exploration +- **DO NOT** guess the implementation based on signatures from Codebase High Level Overview or excerpts you retrieved. Always read the actual source code before using any information about a component in the codebase. This is critical to avoid hallucinations and wrong assumptions. +- When you update or write code, Codebase High Level Overview will become outdated. + +## File Modifications and Component Modifications - You must apply changes to the existing source code using the provided ${[ - FILE_BATCH_EDIT_TOOL_NAME, - FILE_SINGLE_EDIT_TOOL_NAME, - FILE_WRITE_TOOL_NAME, - ].join( - ", " - )} tools. The complete existing source code will be provided in the section of the user prompt. + FILE_BATCH_EDIT_TOOL_NAME, + FILE_SINGLE_EDIT_TOOL_NAME, + FILE_WRITE_TOOL_NAME, + ].join( + ", " + )} tools. The complete existing source code will be provided in the section of the user prompt. - When making replacements inside an existing file, provide the **exact old string** and the **exact new string** with all newlines, spaces, and indentation, being mindful to replace nearby occurrences together to minimize the number of tool calls. - Do NOT create a new markdown file to document each change or summarize your work unless specifically requested by the user. - Do not manually add/modify Dependencies.toml. For Config.toml configuration management, use ${CONFIG_COLLECTOR_TOOL}. @@ -242,10 +255,18 @@ System context: export function getUserPrompt(params: GenerateAgentCodeRequest, tempProjectPath: string, projects: ProjectSource[], codeMapMarkdown?: string) { const content = []; - content.push({ - type: 'text' as const, - text: formatCodebaseStructure(projects, tempProjectPath) - }); + // Add codebase high level summary if available, otherwise fall back to full project structure + if (codeMapMarkdown) { + content.push({ + type: 'text' as const, + text: `\n${codeMapMarkdown}\n` + }); + } else { + content.push({ + type: 'text' as const, + text: formatCodebaseStructure(projects, tempProjectPath) + }); + } // Add code context if available if (params.codeContext) { diff --git a/packages/ballerina-extension/src/features/ai/agent/tools/text-editor.ts b/packages/ballerina-extension/src/features/ai/agent/tools/text-editor.ts index 8a017cf0e8b..8c3619093f8 100644 --- a/packages/ballerina-extension/src/features/ai/agent/tools/text-editor.ts +++ b/packages/ballerina-extension/src/features/ai/agent/tools/text-editor.ts @@ -147,21 +147,21 @@ function validateFilePath(filePath: string): ValidationResult { } function validateLineRange( - offset: number, - limit: number, + startLine: number, + endLine: number, totalLines: number ): ValidationResult { - if (offset < 1 || offset > totalLines) { + if (startLine < 1 || startLine > totalLines) { return { valid: false, - error: `Invalid offset ${offset}. File has ${totalLines} lines.` + error: `Invalid startLine ${startLine}. File has ${totalLines} lines.` }; } - if (limit < 1) { + if (endLine < startLine || endLine > totalLines) { return { valid: false, - error: `Invalid limit ${limit}. Must be at least 1.` + error: `Invalid endLine ${endLine}. Must be >= startLine (${startLine}) and <= ${totalLines}.` }; } @@ -690,10 +690,10 @@ export function createReadExecute( ) { return async (args: { file_path: string; - offset?: number; - limit?: number; + startLine?: number; + endLine?: number; }): Promise => { - const { file_path, offset, limit } = args; + const { file_path, startLine, endLine } = args; // Validate file path const pathValidation = validateFilePath(file_path); @@ -748,10 +748,10 @@ export function createReadExecute( const totalLines = lines.length; // Handle ranged read - if (offset !== undefined && limit !== undefined) { - const validation = validateLineRange(offset, limit, totalLines); + if (startLine !== undefined && endLine !== undefined) { + const validation = validateLineRange(startLine, endLine, totalLines); if (!validation.valid) { - console.error(`[FileReadTool] Invalid line range for file: ${file_path}, offset: ${offset}, limit: ${limit}`); + console.error(`[FileReadTool] Invalid line range for file: ${file_path}, startLine: ${startLine}, endLine: ${endLine}`); const result = { success: false, message: validation.error!, @@ -761,15 +761,15 @@ export function createReadExecute( return result; } - const startIndex = offset - 1; // Convert to 0-based index - const endIndex = Math.min(startIndex + limit, totalLines); + const startIndex = startLine - 1; // Convert to 0-based index + const endIndex = Math.min(endLine, totalLines); const rangedLines = lines.slice(startIndex, endIndex); const rangedContent = truncateLongLines(rangedLines.join('\n')); - console.log(`[FileReadTool] Read lines ${offset} to ${endIndex} from file: ${file_path}`); + console.log(`[FileReadTool] Read lines ${startLine} to ${endIndex} from file: ${file_path}`); const result = { success: true, - message: `Read lines ${offset} to ${endIndex} from '${file_path}' (${endIndex - startIndex} lines). \nContent:${rangedContent}`, + message: `Read lines ${startLine} to ${endIndex} from '${file_path}' (${endIndex - startIndex} lines). \nContent:${rangedContent}`, }; emitFileToolResult(eventHandler, FILE_READ_TOOL_NAME, result, file_path); return result; @@ -821,8 +821,8 @@ type MultiEditExecute = (args: { type ReadExecute = (args: { file_path: string; - offset?: number; - limit?: number; + startLine?: number; + endLine?: number; }) => Promise; // 1. Write Tool @@ -921,18 +921,17 @@ export function createBatchEditTool(execute: MultiEditExecute) { export function createReadTool(execute: ReadExecute) { return tool({ description: `Reads a file from the local filesystem. - ALWAYS prefer reading files mentioned in the user’s message in the chat history first. Only use this tool if you need to read a file that is not present in the chat history. NOTE: The following files are restricted and cannot be read: ${RESTRICTED_READ_FILES.join(", ")}. Usage: - For workspace projects, include the package directory prefix in the file path (e.g., "myPackage/main.bal"). - - You can optionally specify a line offset and limit (especially handy for long files). - - Any lines longer than 2000 characters will be truncated + - From the Codebase High Level Summary, If you know the line range of components to read, **ALWAYS** use the startLine and endLine parameters to read that components insted of files. - The file content will be returned as string - - If the file is very large, consider using the offset and limit parameters to read it in chunks.`, + - Any lines longer than 2000 characters will be truncated + - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents`, inputSchema: z.object({ file_path: z.string().describe(getFilePathDescription("read")), - offset: z.number().optional().describe("The line number to start reading from. Only provide if the file is too large to read at once"), - limit: z.number().optional().describe("The number of lines to read. Only provide if the file is too large to read at once.") + startLine: z.number().optional().describe("The line number to start reading from. Use to read a specific range of lines."), + endLine: z.number().optional().describe("The line number to stop reading at. Use to read a specific range of lines.") }), execute }); diff --git a/packages/ballerina-extension/test/ai/evals/code/result-management/report-generator.ts b/packages/ballerina-extension/test/ai/evals/code/result-management/report-generator.ts index 979230674bc..29db9745553 100644 --- a/packages/ballerina-extension/test/ai/evals/code/result-management/report-generator.ts +++ b/packages/ballerina-extension/test/ai/evals/code/result-management/report-generator.ts @@ -31,6 +31,14 @@ export function generateComprehensiveReport(summary: Summary): void { console.log(` Overall Accuracy: ${summary.accuracy}%`); console.log(` Average LLM Evaluation Rating: ${summary.evaluationSummary.toFixed(2)}/10`); + // Display context retrieval summary + const resultsWithContextEval = summary.results.filter(r => r.contextRetrievalEvaluation !== undefined); + if (resultsWithContextEval.length > 0) { + const relevantCount = resultsWithContextEval.filter(r => r.contextRetrievalEvaluation!.is_relevant).length; + console.log(`\n🔍 CONTEXT RETRIEVAL EVALUATION:`); + console.log(` Relevant: ${relevantCount}/${resultsWithContextEval.length} (${Math.round(relevantCount / resultsWithContextEval.length * 100)}%)`); + } + // Display iteration-specific summaries if multiple iterations if (summary.iterations && summary.iterations > 1 && summary.iterationResults) { logIterationSummaries(summary.iterationResults); @@ -155,6 +163,10 @@ function logSuccessfulCompilations(results: readonly UsecaseResult[]): void { if (result.evaluationResult) { console.log(` LLM Rating: ${result.evaluationResult.rating.toFixed(1)}/10 (${result.evaluationResult.is_correct ? '✅' : '❌'})`); } + if (result.contextRetrievalEvaluation) { + const ctx = result.contextRetrievalEvaluation; + console.log(` Context Retrieval: ${ctx.is_relevant ? '✅' : '❌'} Relevant: ${ctx.is_relevant}`); + } if (result.files.length > 0) { console.log(` Files: ${result.files.map(f => f.fileName).join(', ')}`); } @@ -177,6 +189,10 @@ function logFailedCompilations(results: readonly UsecaseResult[]): void { console.log(` LLM Rating: ${result.evaluationResult.rating.toFixed(1)}/10`); console.log(` LLM Reasoning: ${result.evaluationResult.reasoning.substring(0, 100)}${result.evaluationResult.reasoning.length > 100 ? '...' : ''}`); } + if (result.contextRetrievalEvaluation) { + const ctx = result.contextRetrievalEvaluation; + console.log(` Context Retrieval: ${ctx.is_relevant ? '✅' : '❌'} Relevant: ${ctx.is_relevant}`); + } if (result.errorEvents && result.errorEvents.length > 0) { console.log(` Key Errors:`); diff --git a/packages/ballerina-extension/test/ai/evals/code/result-management/result-conversion.ts b/packages/ballerina-extension/test/ai/evals/code/result-management/result-conversion.ts index 37c66435d0c..696b66ecdd7 100644 --- a/packages/ballerina-extension/test/ai/evals/code/result-management/result-conversion.ts +++ b/packages/ballerina-extension/test/ai/evals/code/result-management/result-conversion.ts @@ -75,6 +75,7 @@ export function convertTestResultToUsecaseResult(testResult: TestCaseResult, ite duration: testResult.result.duration, timestamp: testResult.result.startTime, evaluationResult: testResult.evaluationResult, + contextRetrievalEvaluation: testResult.contextRetrievalEvaluation, errorEvents: errorEvents.length > 0 ? errorEvents : undefined, toolEvents: toolEvents.length > 0 ? toolEvents : undefined, iteration, diff --git a/packages/ballerina-extension/test/ai/evals/code/result-management/result-persistence.ts b/packages/ballerina-extension/test/ai/evals/code/result-management/result-persistence.ts index 4837027dfca..35f24b94eb4 100644 --- a/packages/ballerina-extension/test/ai/evals/code/result-management/result-persistence.ts +++ b/packages/ballerina-extension/test/ai/evals/code/result-management/result-persistence.ts @@ -46,6 +46,7 @@ export async function persistUsecaseResult( iteration: usecaseResult.iteration, toolEvents: usecaseResult.toolEvents, evaluationResult: usecaseResult.evaluationResult, + contextRetrievalEvaluation: usecaseResult.contextRetrievalEvaluation, usage: usecaseResult.usage ? { totalTokens: usecaseResult.usage.initial.inputTokens + usecaseResult.usage.initial.outputTokens + usecaseResult.usage.repairs.reduce((sum, repair) => sum + repair.inputTokens + repair.outputTokens, 0), @@ -91,6 +92,7 @@ export async function persistUsecaseResult( for (const file of usecaseResult.files) { const filePath = path.join(codeDir, file.fileName); + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); await fs.promises.writeFile(filePath, file.content); } diff --git a/packages/ballerina-extension/test/ai/evals/code/test-cases.ts b/packages/ballerina-extension/test/ai/evals/code/test-cases.ts index 210c56c0e4e..40431ea0a76 100644 --- a/packages/ballerina-extension/test/ai/evals/code/test-cases.ts +++ b/packages/ballerina-extension/test/ai/evals/code/test-cases.ts @@ -276,22 +276,22 @@ export const langlibTestCases = [ projectPath: "bi_init" }, { - // findAll(), substring(), push(), findGroups(), string.length(), push(), startswith(), fromJsonString(), cloneWithType(), arr.length() + // findAll(), substring(), push(), findGroups(), string.length(), push(), startswith(), fromJsonString(), cloneWithType(), arr.length() prompt: "Build an integration that scans HTML email content for all URLs, finds the first clickable link, counts total links and XML attachments, parses XML metadata, checks if URLs start with https, deserializes embedded JSON data with type safety, and measures the size of extracted collections.", projectPath: "bi_init" }, { - // toUpperAscii(), cloneReadOnly(), string.length() + // toUpperAscii(), cloneReadOnly(), string.length() prompt: "Create a service that reads product names from inventory, alphabetically compares and arranges them using unicode ordering, removes the oldest and newest entries, converts remaining names to uppercase, creates immutable copies, measures text lengths, and exports as an array structure.", projectPath: "bi_init" }, { - // trim(), replaceAll(), decimal:fromString(), float:fromString(), array:length(), .toString(), message(), push() + // trim(), replaceAll(), decimal:fromString(), float:fromString(), array:length(), .toString(), message(), push() prompt: "Develop an API that reads scientific measurements from spreadsheets, converts text values to floating point numbers, applies exponential calculations for growth projections, parses currency amounts, validates data types match requirements, aggregates results, and returns JSON output after trimming whitespace.", projectPath: "bi_init" }, { - // length(), push(), split(), trim(), toLowerAscii(), includes(), keys(), removeAll(), unshift(), cloneReadOnly(), join() + // length(), push(), split(), trim(), toLowerAscii(), includes(), keys(), removeAll(), unshift(), cloneReadOnly(), join() prompt: "Write an integration that processes comma-separated log entries, splits them into fields, checks for error keywords, removes sensitive configuration keys conditionally, clears entire cache when needed, prepends priority entries to the front, creates read-only snapshots, combines messages, and finds specific entry positions.", projectPath: "bi_init" }, @@ -301,7 +301,7 @@ export const langlibTestCases = [ projectPath: "bi_init" }, { - // length(), toCodePointInt(), regexp:fromString(), isFullMatch(), matches(), matchAt(), substring(), push(), toLowerAscii(), trim(), getCodePoint(), fromCodePointInt() + // length(), toCodePointInt(), regexp:fromString(), isFullMatch(), matches(), matchAt(), substring(), push(), toLowerAscii(), trim(), getCodePoint(), fromCodePointInt() prompt: "Build a validation service that examines text input character by character, extracts unicode code points at specific positions, converts characters to numeric codes, validates entire strings match patterns, checks pattern matches at exact locations, filters valid entries, interprets yes/no flags, and generates text reports.", projectPath: "bi_init" }, @@ -326,7 +326,7 @@ export const langlibTestCases = [ projectPath: "bi_init" }, { - // message(), findGroups(), findAllGroups(), toLowerAscii(), includes(), isFullMatch(), matchAt(), find(), substring(), find(), replace(), trim(), replaceAll(), push(), clone(), codePointCompare() + // message(), findGroups(), findAllGroups(), toLowerAscii(), includes(), isFullMatch(), matchAt(), find(), substring(), find(), replace(), trim(), replaceAll(), push(), clone(), codePointCompare() prompt: "Develop a log analysis tool that extracts first matching pattern groups from log lines, captures all pattern groups across entries, checks if lines contain error keywords, identifies lines starting with timestamps, filters relevant entries, sorts messages using unicode comparison, and processes all metadata key-value pairs.", projectPath: "bi_init" }, @@ -339,12 +339,12 @@ export const langlibTestCases = [ // Langlib test cases for existing code { - // toLowerAscii(), split(), push(), length(), substring() + // toLowerAscii(), split(), push(), length(), substring() prompt: "Develop a resource function for processing customers that receives customer data via POST /customers/process endpoint. Parse the incoming JSON payload into typed customer records, export the processed customer data as formatted JSON output, and validate email domains are in lowercase format before storing the customer information.", projectPath: "langlib_with_existing_code" }, { - // message(), cloneWithType(), toJson(), toString(), join() + // message(), cloneWithType(), toJson(), toString(), join() prompt: "Develop a resource function for the POST /customers endpoint that receives customer data. Parse the incoming JSON payload, convert it to a customer record type, serialize the customer data back to JSON format, and ensure all values are converted to string representations for logging.", projectPath: "langlib_with_existing_code" }, @@ -365,10 +365,133 @@ export const langlibTestCases = [ } ]; +export const testCasesForCodeIndexing = [ + // Covers: Adding a new feature + { + prompt: "Add an endpoint to search patients by last name, return the results as a FHIR Bundle, following the same style as existing read endpoints.", + projectPath: "healthcare_sample" + }, + { + prompt: "We need an audit trail whenever a patient or encounter is deleted. Add structured logging that captures the resource type, ID, and timestamp.", + projectPath: "healthcare_sample" + }, + { + prompt: "I want a full audit trail before any patient or encounter is deleted. Before removing the record, fetch it and log the key fields — name and birthdate for patients, status and period for encounters — along with the resource type and timestamp.", + projectPath: "healthcare_sample" + }, + { + prompt: "Add a createdAt timestamp to order creation response so callers know when the order was accepted.", + projectPath: "order_management_system" + }, + { + prompt: "Before writing a new order to the database, check that the order has at least one line item. Return an error early if it doesn't.", + projectPath: "order_management_system" + }, + + // Covers: Modifying an existing feature + { + prompt: "I want callers to be able to pass their own patient ID (like a hospital MRN) on creation. Use it if provided, otherwise auto-generate one.", + projectPath: "healthcare_sample" + }, + { + prompt: "Change generate patient id to use UUID v4 instead of the incrementing counter — we need IDs to survive service restarts.", + projectPath: "healthcare_sample" + }, + { + prompt: "Migrate the patient and encounter persistence layer from MySQL to PostgreSQL, including the driver, connection setup, and config.", + projectPath: "healthcare_sample" + }, + { + prompt: "The date normalisation and RFC 3339 conversion always go together. Merge them into one function.", + projectPath: "salesforce_slack_integration" + }, + { + prompt: "Move build lead conversion details logic into functions.bal file", + projectPath: "salesforce_slack_integration" + }, + { + prompt: "Replace the order service REST API with GraphQL. Make sure the schema covers all the fields the current REST endpoints accept and return.", + projectPath: "order_management_system" + }, + { + prompt: "The Shopify customer creation handler doesn't retry if Stripe returns a transient error. Add retry logic with up to 3 attempts before giving up.", + projectPath: "shopify_stripe_integration_errors" + }, + { + prompt: "I want to use generateId from order utils inside db operations.bal, can you update the code?", + projectPath: "order_management_system" + }, + { + prompt: "onUpdate function is catching errors from the lead conversion and logging them instead of propagating. Change it to let errors bubble up directly.", + projectPath: "salesforce_slack_integration_errors" + }, + + // Covers: Removing an existing feature + { + prompt: "Remove the team-routing feature — the config, the channel-selection logic, and any related types. All lead notifications should go to the default channel.", + projectPath: "salesforce_slack_integration" + }, + { + prompt: "We're not filtering leads anymore — every converted lead should produce a notification. Remove the filter configuration, the function that decides whether a lead passes the filters, and the call into it from the main processing flow.", + projectPath: "salesforce_slack_integration" + }, + + // Covers: Understanding/Exploration + { + prompt: "How does the current SMS flow implementation work?", + projectPath: "healthcare_sample" + }, + { + prompt: "How does the integration avoid sending duplicate Slack messages for the same lead? Is it safe if two events arrive at the same time?", + projectPath: "salesforce_slack_integration" + }, + + // Covers: Unrelated to the project + { + prompt: "I'd like to add a feature to the service that fetches the current Bitcoin price every hour from a public crypto exchange and posts the price as a tweet on our company Twitter account.", + projectPath: "healthcare_sample" + }, + { + prompt: "Add a job that fetches the Bitcoin price every hour and posts it to Slack.", + projectPath: "salesforce_slack_integration" + }, + + // Covers: Common tasks + { + prompt: "Add doc comments to all public functions", + projectPath: "salesforce_slack_integration" + }, + { + prompt: "There's an error on main.bal. Can you fix it?", + projectPath: "shopify_stripe_integration_errors" + }, + + // Covers: Overengineed tasks + { + prompt: "What's the difference between isolated and non-isolated functions in Ballerina?", + projectPath: "salesforce_slack_integration" + }, + + // Covers: Adding/Updating tests + { + prompt: "Add a test for the delete endpoint — create a reservation, delete it, then check that fetching reservations for that user returns an empty list.", + projectPath: "hotel_reservation" + }, + { + prompt: "The existing test reservation test only checks the room number. Update it to also verify the checkin date, checkout date, and user details in the response match what was sent in the request.", + projectPath: "hotel_reservation" + }, + { + prompt: "Write tests for the order utils module — ID generation, timestamp format, and line total calculation.", + projectPath: "order_management_system" + } +]; + export let testCases = []; testCases.push(...initialTestCases); testCases.push(...httpTestCases); testCases.push(...textEditSpecializedTestCases); -testCases.push(...testCasesForExistingProject); +testCases.push(...testCasesForExistingProject); testCases.push(...testCasesForExistingSemanticErrors); testCases.push(...langlibTestCases); +testCases.push(...testCasesForCodeIndexing); diff --git a/packages/ballerina-extension/test/ai/evals/code/types/result-types.ts b/packages/ballerina-extension/test/ai/evals/code/types/result-types.ts index ab3947e8563..00f6fe215df 100644 --- a/packages/ballerina-extension/test/ai/evals/code/types/result-types.ts +++ b/packages/ballerina-extension/test/ai/evals/code/types/result-types.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com/) All Rights Reserved. -import { LLMEvaluationResult } from "../utils/evaluator-utils"; +import { LLMEvaluationResult, ContextRetrievalEvaluationResult } from "../utils/evaluator-utils"; // WSO2 LLC. licenses this file to you under the Apache License, // Version 2.0 (the "License"); you may not use this file except @@ -85,6 +85,7 @@ export interface UsecaseResult { readonly toolEvents?: readonly ToolEvent[]; readonly iteration?: number; readonly evaluationResult: LLMEvaluationResult; + readonly contextRetrievalEvaluation?: ContextRetrievalEvaluationResult; readonly usage?: { readonly initial: { readonly inputTokens: number; @@ -210,6 +211,7 @@ export interface UsecaseCompact { readonly iteration?: number; readonly toolEvents?: readonly ToolEvent[]; readonly evaluationResult: LLMEvaluationResult; + readonly contextRetrievalEvaluation?: ContextRetrievalEvaluationResult; readonly usage?: { readonly totalTokens: number; readonly cacheHits: number; diff --git a/packages/ballerina-extension/test/ai/evals/code/types/test-types.ts b/packages/ballerina-extension/test/ai/evals/code/types/test-types.ts index d853e7eafdc..5a4b6217199 100644 --- a/packages/ballerina-extension/test/ai/evals/code/types/test-types.ts +++ b/packages/ballerina-extension/test/ai/evals/code/types/test-types.ts @@ -15,7 +15,7 @@ // under the License. import { ChatNotify, CodeContext, FileAttatchment, OperationType, SourceFile } from "@wso2/ballerina-core"; -import { LLMEvaluationResult } from "../utils/evaluator-utils"; +import { LLMEvaluationResult, ContextRetrievalEvaluationResult } from "../utils/evaluator-utils"; /** * Test use case definition @@ -119,5 +119,6 @@ export interface TestCaseResult { readonly noDiagnosticsCheck: boolean; }; readonly evaluationResult?: LLMEvaluationResult; + readonly contextRetrievalEvaluation?: ContextRetrievalEvaluationResult; readonly generatedSources?: readonly SourceFile[]; } diff --git a/packages/ballerina-extension/test/ai/evals/code/utils/evaluator-utils.ts b/packages/ballerina-extension/test/ai/evals/code/utils/evaluator-utils.ts index 8b76e4e07cd..c5d2088c7e2 100644 --- a/packages/ballerina-extension/test/ai/evals/code/utils/evaluator-utils.ts +++ b/packages/ballerina-extension/test/ai/evals/code/utils/evaluator-utils.ts @@ -20,6 +20,7 @@ import { createAnthropic } from "@ai-sdk/anthropic"; import path from "path"; import fs from "fs"; import { z } from 'zod'; +import { ToolEvent } from '../types'; export interface LLMEvaluationResult { is_correct: boolean; @@ -27,6 +28,14 @@ export interface LLMEvaluationResult { rating: number; } +export interface ContextRetrievalEvaluationResult { + is_relevant: boolean; + covered: string; + missing: string; + critical_gaps: string; + recommendations: string; +} + const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); @@ -153,6 +162,171 @@ Use the submit_evaluation tool to provide your assessment.`; } } +function extractFileReadSection(toolEvents: readonly ToolEvent[], initialSource: SourceFile[]): string { + const contentByPath = new Map(); + for (const file of initialSource) { + const normalized = file.filePath.replace(/\\/g, '/'); + contentByPath.set(normalized, file.content); + const parts = normalized.split('/'); + for (let i = 1; i < parts.length; i++) { + contentByPath.set(parts.slice(i).join('/'), file.content); + } + } + + const seen = new Set(); + const fileReads: string[] = []; + + for (const event of toolEvents) { + if (event.type === 'tool_result' && event.toolName === 'file_read') { + const fileName = event.toolOutput?.fileName || 'unknown'; + if (seen.has(fileName)) continue; + seen.add(fileName); + + const content = contentByPath.get(fileName) ?? contentByPath.get(fileName.replace(/\\/g, '/')); + if (content) { + fileReads.push(`--- File: ${fileName} ---\n${content}`); + } else { + fileReads.push(`--- File: ${fileName} --- (content not available)`); + } + } + } + + if (fileReads.length === 0) { + return "No files were read by the agent."; + } + + return fileReads.join('\n\n'); +} + +const contextRetrievalSchema = z.object({ + is_relevant: z.boolean().describe( + 'True if the agent retrieved every existing component (function, type, constant, etc.) from the codebase needed to fulfill the user query. False otherwise.' + ), + covered: z.string().describe( + 'Existing components from the codebase the agent retrieved that are relevant to the query. ' + + 'Format: "- \\n - \\n - ". ' + + 'Write "None" if nothing relevant was retrieved.' + ), + missing: z.string().describe( + 'Existing components from the codebase that were NOT retrieved but are required. ' + + 'Each entry must give a definitive reason the component is required (e.g. "the change must call this function"). No "likely", "possibly", or speculative language. ' + + 'Do NOT list components that the agent must newly create. ' + + 'Format: "- \\n - \\n - ". ' + + 'Write "None" if nothing is missing.' + ), + critical_gaps: z.string().describe( + 'Subset of "missing" whose absence blocks correct implementation. Same format and strictness rules as missing. Write "None" if there are no critical gaps. If any entry is listed here, is_relevant must be false.' + ), + recommendations: z.string().describe( + 'Optional suggestions to complete the retrieval. Write "None" if no further retrieval is needed.' + ) +}); + +/** + * Uses an LLM to evaluate whether the agent retrieved relevant context + * via the read tool for implementing the user query. + * + * @param userQuery The original request from the user. + * @param initialSource The complete codebase (ground truth). + * @param toolEvents The tool events captured during test execution. + * @returns A promise that resolves to a context retrieval evaluation result. + */ +export async function evaluateContextRetrievalWithLLM( + userQuery: string, + initialSource: SourceFile[], + toolEvents: readonly ToolEvent[] +): Promise { + console.log("🤖 Starting LLM-based context retrieval evaluation..."); + + const stringifySources = (sources: SourceFile[]): string => { + if (sources.length === 0) return "No files in the project."; + return sources.map(file => `--- File: ${file.filePath} ---\n${file.content}`).join("\n\n"); + }; + + const initialCodeString = stringifySources(initialSource); + const fileReadSection = extractFileReadSection(toolEvents, initialSource); + + const systemPrompt = `You are a code retrieval evaluator. + +Your role is to: +1. Compare the codebase against the context the agent retrieved via file reads +2. Determine if the agent retrieved every existing component needed to fulfill the user query +3. List what was covered and, if anything is missing, state a concrete reason for each gap + +Be strict: +- Components the agent must newly create are NOT retrieval requirements. +- If the required pattern is already demonstrated by another component the agent retrieved (e.g. an analogous endpoint, a sibling function, a parallel type), it is NOT missing. Only flag a component if the agent could not have written correct code without reading it. +- Do not use speculative language. If you cannot point to a specific detail (signature, name, type, value) in the missing component that is not derivable from any retrieved component, do not list it.`; + + const userPrompt = ` +# User Query +\`\`\` +${userQuery} +\`\`\` + +# Complete Codebase (Ground Truth) +\`\`\`ballerina +${initialCodeString} +\`\`\` + +# Context Retrieved by the Agent +${fileReadSection} + +--- + +Evaluate whether the agent retrieved every existing component from the codebase required to fulfill the user query. Consider: +- Which existing components are needed to implement the query? +- Did the agent retrieve each of them? +- For anything missing, what is the concrete reason it is required? + +Use the submit_evaluation tool to provide your assessment.`; + + try { + const result = await generateText({ + model: anthropic('claude-sonnet-4-5-20250929'), + system: systemPrompt, + prompt: userPrompt, + temperature: 0.1, + tools: { + submit_evaluation: { + description: + 'Submit a comprehensive evaluation of whether the agent retrieved sufficient context for implementing the user query.', + inputSchema: contextRetrievalSchema, + } + }, + toolChoice: { + type: 'tool', + toolName: 'submit_evaluation' + }, + maxRetries: 1, + }); + + const toolCall = result.toolCalls[0]; + + if (!toolCall || toolCall.toolName !== 'submit_evaluation') { + throw new Error("Expected submit_evaluation tool call but received none"); + } + + const evaluationResult = toolCall.input as ContextRetrievalEvaluationResult; + + console.log(`✅ Context Retrieval Evaluation Complete. Relevant: ${evaluationResult.is_relevant}`); + console.log(` Covered: ${evaluationResult.covered}`); + console.log(` Missing: ${evaluationResult.missing}`); + console.log(` Critical Gaps: ${evaluationResult.critical_gaps}`); + return evaluationResult; + + } catch (error) { + console.error("Error during context retrieval evaluation:", error); + return { + is_relevant: false, + covered: "None", + missing: `Failed to evaluate due to an error: ${error instanceof Error ? error.message : "Unknown error"}`, + critical_gaps: "Evaluation failed", + recommendations: "Retry evaluation" + }; + } +} + export async function getProjectSource(dirPath: string): Promise { const projectRoot = dirPath; diff --git a/packages/ballerina-extension/test/ai/evals/code/utils/test-validation.ts b/packages/ballerina-extension/test/ai/evals/code/utils/test-validation.ts index b1af029b746..721164b1017 100644 --- a/packages/ballerina-extension/test/ai/evals/code/utils/test-validation.ts +++ b/packages/ballerina-extension/test/ai/evals/code/utils/test-validation.ts @@ -15,8 +15,9 @@ // under the License. import { TestEventResult, TestUseCase, TestCaseResult } from '../types'; -import { evaluateCodeWithLLM, LLMEvaluationResult } from './evaluator-utils'; +import { evaluateCodeWithLLM, evaluateContextRetrievalWithLLM, LLMEvaluationResult } from './evaluator-utils'; import { SourceFile } from '@wso2/ballerina-core'; +import { ToolEvent, ToolCallEvent, ToolResultEvent, EvalsToolResultEvent } from '../types'; /** * Validates test result based on error events and diagnostics @@ -43,6 +44,20 @@ export async function validateTestResult(result: TestEventResult, useCase: TestU } const evaluation: LLMEvaluationResult = await evaluateCodeWithLLM(useCase.usecase, initialSources, finalSources); + const toolEvents: ToolEvent[] = result.events + .filter(event => event.type === 'tool_call' || event.type === 'tool_result' || event.type === 'evals_tool_result') + .map(event => { + if (event.type === 'tool_call') { + return { type: 'tool_call', toolName: event.toolName } as ToolCallEvent; + } else if (event.type === 'tool_result') { + return { type: 'tool_result', toolName: event.toolName, toolOutput: event.toolOutput } as ToolResultEvent; + } else { + return { type: 'evals_tool_result', toolName: event.toolName, output: event.output } as EvalsToolResultEvent; + } + }); + + const contextRetrievalEval = await evaluateContextRetrievalWithLLM(useCase.usecase, initialSources, toolEvents); + return { useCase, result, @@ -50,6 +65,7 @@ export async function validateTestResult(result: TestEventResult, useCase: TestU failureReason: failureReason || undefined, validationDetails, evaluationResult: evaluation, + contextRetrievalEvaluation: contextRetrievalEval, generatedSources: finalSources }; } diff --git a/packages/ballerina-extension/test/data/healthcare_sample/Ballerina.toml b/packages/ballerina-extension/test/data/healthcare_sample/Ballerina.toml new file mode 100644 index 00000000000..e2b20546342 --- /dev/null +++ b/packages/ballerina-extension/test/data/healthcare_sample/Ballerina.toml @@ -0,0 +1,14 @@ +[package] +org = "wso2" +name = "healthcare_sample" +version = "1.0.0" +distribution = "2201.12.3" +authors = ["Ballerina"] +keywords = ["Healthcare", "FHIR", "r4"] +template = true + +[[platform.java21.dependency]] +groupId = "io.ballerina.stdlib" +artifactId = "persist.sql-native" +version = "1.6.0" + diff --git a/packages/ballerina-extension/test/data/healthcare_sample/Config.toml b/packages/ballerina-extension/test/data/healthcare_sample/Config.toml new file mode 100644 index 00000000000..bca6a7d7a7e --- /dev/null +++ b/packages/ballerina-extension/test/data/healthcare_sample/Config.toml @@ -0,0 +1,8 @@ +SERVER_BASE_URL = "http://localhost:9090/fhir/r4" + +[healthcare_sample.db] +host = "localhost" +port = 3306 +user = "" +password = "" +database = "" diff --git a/packages/ballerina-extension/test/data/healthcare_sample/encounter_api_config.bal b/packages/ballerina-extension/test/data/healthcare_sample/encounter_api_config.bal new file mode 100644 index 00000000000..f09af70397e --- /dev/null +++ b/packages/ballerina-extension/test/data/healthcare_sample/encounter_api_config.bal @@ -0,0 +1,108 @@ +// Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// +// AUTO-GENERATED FILE. +// +// This file is auto-generated by Ballerina. +// Developers are allowed to modify this file as per the requirement. + +import ballerinax/health.fhir.r4; + +final r4:ResourceAPIConfig encounterApiConfig = { + resourceType: "Encounter", + profiles: [ + "http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter" + + ], + defaultProfile: (), + searchParameters: [ + { + name: "class", + active: true, + information: { + description: "**Classification of patient encounter** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-encounter-class" + } + }, + + { + name: "_id", + active: true, + information: { + description: "**Logical id of this artifact** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-encounter-id" + } + }, + + { + name: "patient", + active: true, + information: { + description: "**The patient or group present at the encounter** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-encounter-patient" + } + }, + + { + name: "status", + active: true, + information: { + description: "**planned | arrived | triaged | in-progress | onleave | finished | cancelled +** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-encounter-status" + } + }, + + { + name: "date", + active: true, + information: { + description: "**A date within the period the Encounter lasted** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-encounter-date" + } + }, + + { + name: "type", + active: true, + information: { + description: "**Specific type of encounter** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-encounter-type" + } + }, + + { + name: "identifier", + active: true, + information: { + description: "**Identifier(s) by which this encounter is known** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-encounter-identifier" + } + } + + ], + operations: [ + + ], + serverConfig: (), + authzConfig: () +}; diff --git a/packages/ballerina-extension/test/data/healthcare_sample/mapping.bal b/packages/ballerina-extension/test/data/healthcare_sample/mapping.bal new file mode 100644 index 00000000000..732099f3505 --- /dev/null +++ b/packages/ballerina-extension/test/data/healthcare_sample/mapping.bal @@ -0,0 +1,84 @@ +// Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import healthcare_sample.db; + +import ballerina/uuid; +import ballerinax/health.fhir.r4.uscore311; + +// ############################################################################################################################################# +// # Mapper methods # +// ############################################################################################################################################# + +public isolated function mapCustomPatientToFHIR(db:PatientDataOptionalized patient) returns uscore311:USCorePatientProfile => { + id: patient?.id ?: uuid:createType1AsString(), + identifier: [ + { + system: "http://university-hospital.com/pid", + value: patient?.id ?: generatePatientId() + } + ], + gender: (patient?.gender ?: "unkown"), + name: [ + { + given: mapNameToGiven(patient?.name) + } + ], + birthDate: patient?.birthDate +}; + +public isolated function mapFhirToCustomPatient(uscore311:USCorePatientProfile patient) returns db:PatientDataInsert => { + gender: patient.gender, + name: mapGivenToName(patient.name[0].given), + id: uuid:createType1AsString(), + birthDate: patient.birthDate +}; + +// ############################################################################################################################################# +// # Util methods # +// ############################################################################################################################################# +isolated function mapGivenToName(string[]? given) returns string { + if given is string[] { + return given[0]; + } + return ""; +} + +isolated function mapNameToGiven(string? name) returns string[] { + if name is string { + return [name]; + } + return []; +} + +isolated int currentId = 4; + +isolated function generatePatientId() returns string { + lock { + string numberPart = currentId.toString(); + int paddingLength = 3 - numberPart.length(); + + if paddingLength > 0 { + foreach int i in 1 ... paddingLength { + numberPart = string `0${numberPart}`; + } + } + + numberPart = "P" + numberPart; + currentId += 1; + return numberPart; + } +} diff --git a/packages/ballerina-extension/test/data/healthcare_sample/modules/db/persist_client.bal b/packages/ballerina-extension/test/data/healthcare_sample/modules/db/persist_client.bal new file mode 100644 index 00000000000..212a795ef9c --- /dev/null +++ b/packages/ballerina-extension/test/data/healthcare_sample/modules/db/persist_client.bal @@ -0,0 +1,175 @@ +// Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// AUTO-GENERATED FILE. DO NOT MODIFY. + +// This file is an auto-generated file by Ballerina persistence layer for model. +// It should not be modified by hand. + +import ballerina/jballerina.java; +import ballerina/persist; +import ballerina/sql; +import ballerinax/mysql; +import ballerinax/mysql.driver as _; +import ballerinax/persist.sql as psql; + +const PATIENT_DATA = "patientdata"; +const ENCOUNTER_DATA = "encounterdata"; + +public isolated client class Client { + *persist:AbstractPersistClient; + + private final mysql:Client dbClient; + + private final map persistClients; + + private final record {|psql:SQLMetadata...;|} & readonly metadata = { + [PATIENT_DATA]: { + entityName: "PatientData", + tableName: "PatientData", + fieldMetadata: { + id: {columnName: "id"}, + name: {columnName: "name"}, + gender: {columnName: "gender"}, + birthDate: {columnName: "birthDate"} + }, + keyFields: ["id"] + }, + [ENCOUNTER_DATA]: { + entityName: "EncounterData", + tableName: "EncounterData", + fieldMetadata: { + id: {columnName: "id"}, + status: {columnName: "status"}, + encounterClassSystem: {columnName: "encounterClassSystem"}, + encounterClassCode: {columnName: "encounterClassCode"}, + encounterClassDisplay: {columnName: "encounterClassDisplay"}, + typeText: {columnName: "typeText"}, + subjectRef: {columnName: "subjectRef"}, + periodStart: {columnName: "periodStart"}, + periodEnd: {columnName: "periodEnd"} + }, + keyFields: ["id"] + } + }; + + public isolated function init() returns persist:Error? { + mysql:Client|error dbClient = new (host = host, user = user, password = password, database = database, port = port, options = connectionOptions); + if dbClient is error { + return error(dbClient.message()); + } + self.dbClient = dbClient; + self.persistClients = { + [PATIENT_DATA]: check new (dbClient, self.metadata.get(PATIENT_DATA), psql:MYSQL_SPECIFICS), + [ENCOUNTER_DATA]: check new (dbClient, self.metadata.get(ENCOUNTER_DATA), psql:MYSQL_SPECIFICS) + }; + } + + isolated resource function get patientdata(PatientDataTargetType targetType = <>, sql:ParameterizedQuery whereClause = ``, sql:ParameterizedQuery orderByClause = ``, sql:ParameterizedQuery limitClause = ``, sql:ParameterizedQuery groupByClause = ``) returns stream = @java:Method { + 'class: "io.ballerina.stdlib.persist.sql.datastore.MySQLProcessor", + name: "query" + } external; + + isolated resource function get patientdata/[string id](PatientDataTargetType targetType = <>) returns targetType|persist:Error = @java:Method { + 'class: "io.ballerina.stdlib.persist.sql.datastore.MySQLProcessor", + name: "queryOne" + } external; + + isolated resource function post patientdata(PatientDataInsert[] data) returns string[]|persist:Error { + psql:SQLClient sqlClient; + lock { + sqlClient = self.persistClients.get(PATIENT_DATA); + } + _ = check sqlClient.runBatchInsertQuery(data); + return from PatientDataInsert inserted in data + select inserted.id; + } + + isolated resource function put patientdata/[string id](PatientDataUpdate value) returns PatientData|persist:Error { + psql:SQLClient sqlClient; + lock { + sqlClient = self.persistClients.get(PATIENT_DATA); + } + _ = check sqlClient.runUpdateQuery(id, value); + return self->/patientdata/[id].get(); + } + + isolated resource function delete patientdata/[string id]() returns PatientData|persist:Error { + PatientData result = check self->/patientdata/[id].get(); + psql:SQLClient sqlClient; + lock { + sqlClient = self.persistClients.get(PATIENT_DATA); + } + _ = check sqlClient.runDeleteQuery(id); + return result; + } + + isolated resource function get encounterdata(EncounterDataTargetType targetType = <>, sql:ParameterizedQuery whereClause = ``, sql:ParameterizedQuery orderByClause = ``, sql:ParameterizedQuery limitClause = ``, sql:ParameterizedQuery groupByClause = ``) returns stream = @java:Method { + 'class: "io.ballerina.stdlib.persist.sql.datastore.MySQLProcessor", + name: "query" + } external; + + isolated resource function get encounterdata/[string id](EncounterDataTargetType targetType = <>) returns targetType|persist:Error = @java:Method { + 'class: "io.ballerina.stdlib.persist.sql.datastore.MySQLProcessor", + name: "queryOne" + } external; + + isolated resource function post encounterdata(EncounterDataInsert[] data) returns string[]|persist:Error { + psql:SQLClient sqlClient; + lock { + sqlClient = self.persistClients.get(ENCOUNTER_DATA); + } + _ = check sqlClient.runBatchInsertQuery(data); + return from EncounterDataInsert inserted in data + select inserted.id; + } + + isolated resource function put encounterdata/[string id](EncounterDataUpdate value) returns EncounterData|persist:Error { + psql:SQLClient sqlClient; + lock { + sqlClient = self.persistClients.get(ENCOUNTER_DATA); + } + _ = check sqlClient.runUpdateQuery(id, value); + return self->/encounterdata/[id].get(); + } + + isolated resource function delete encounterdata/[string id]() returns EncounterData|persist:Error { + EncounterData result = check self->/encounterdata/[id].get(); + psql:SQLClient sqlClient; + lock { + sqlClient = self.persistClients.get(ENCOUNTER_DATA); + } + _ = check sqlClient.runDeleteQuery(id); + return result; + } + + remote isolated function queryNativeSQL(sql:ParameterizedQuery sqlQuery, typedesc rowType = <>) returns stream = @java:Method { + 'class: "io.ballerina.stdlib.persist.sql.datastore.MySQLProcessor" + } external; + + remote isolated function executeNativeSQL(sql:ParameterizedQuery sqlQuery) returns psql:ExecutionResult|persist:Error = @java:Method { + 'class: "io.ballerina.stdlib.persist.sql.datastore.MySQLProcessor" + } external; + + public isolated function close() returns persist:Error? { + error? result = self.dbClient.close(); + if result is error { + return error(result.message()); + } + return result; + } +} + diff --git a/packages/ballerina-extension/test/data/healthcare_sample/modules/db/persist_db_config.bal b/packages/ballerina-extension/test/data/healthcare_sample/modules/db/persist_db_config.bal new file mode 100644 index 00000000000..ee49335639d --- /dev/null +++ b/packages/ballerina-extension/test/data/healthcare_sample/modules/db/persist_db_config.bal @@ -0,0 +1,30 @@ +// Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// AUTO-GENERATED FILE. DO NOT MODIFY. + +// This file is an auto-generated file by Ballerina persistence layer. +// It should not be modified by hand. + +import ballerinax/mysql; + +configurable int port = ?; +configurable string host = ?; +configurable string user = ?; +configurable string database = ?; +configurable string password = ?; +configurable mysql:Options & readonly connectionOptions = {}; + diff --git a/packages/ballerina-extension/test/data/healthcare_sample/modules/db/persist_types.bal b/packages/ballerina-extension/test/data/healthcare_sample/modules/db/persist_types.bal new file mode 100644 index 00000000000..c5d7c3a6854 --- /dev/null +++ b/packages/ballerina-extension/test/data/healthcare_sample/modules/db/persist_types.bal @@ -0,0 +1,84 @@ +// Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// AUTO-GENERATED FILE. DO NOT MODIFY. + +// This file is an auto-generated file by Ballerina persistence layer for model. +// It should not be modified by hand. + +public type PatientData record {| + readonly string id; + string? name; + string? gender; + string? birthDate; +|}; + +public type PatientDataOptionalized record {| + string id?; + string? name?; + string? gender?; + string? birthDate?; +|}; + +public type PatientDataTargetType typedesc; + +public type PatientDataInsert PatientData; + +public type PatientDataUpdate record {| + string? name?; + string? gender?; + string? birthDate?; +|}; + +public type EncounterData record {| + readonly string id; + string status; + string? encounterClassSystem; + string? encounterClassCode; + string? encounterClassDisplay; + string? typeText; + string subjectRef; + string? periodStart; + string? periodEnd; +|}; + +public type EncounterDataOptionalized record {| + string id?; + string status?; + string? encounterClassSystem?; + string? encounterClassCode?; + string? encounterClassDisplay?; + string? typeText?; + string subjectRef?; + string? periodStart?; + string? periodEnd?; +|}; + +public type EncounterDataTargetType typedesc; + +public type EncounterDataInsert EncounterData; + +public type EncounterDataUpdate record {| + string status?; + string? encounterClassSystem?; + string? encounterClassCode?; + string? encounterClassDisplay?; + string? typeText?; + string subjectRef?; + string? periodStart?; + string? periodEnd?; +|}; + diff --git a/packages/ballerina-extension/test/data/healthcare_sample/modules/db/script.sql b/packages/ballerina-extension/test/data/healthcare_sample/modules/db/script.sql new file mode 100644 index 00000000000..a0f1a51cf5a --- /dev/null +++ b/packages/ballerina-extension/test/data/healthcare_sample/modules/db/script.sql @@ -0,0 +1,30 @@ +-- AUTO-GENERATED FILE. + +-- This file is an auto-generated file by Ballerina persistence layer for model. +-- Please verify the generated scripts and execute them against the target DB server. + +DROP TABLE IF EXISTS `EncounterData`; +DROP TABLE IF EXISTS `PatientData`; + +CREATE TABLE `PatientData` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191), + `gender` VARCHAR(191), + `birthDate` VARCHAR(191), + PRIMARY KEY(`id`) +); + +CREATE TABLE `EncounterData` ( + `id` VARCHAR(191) NOT NULL, + `status` VARCHAR(191) NOT NULL, + `encounterClassSystem` VARCHAR(191), + `encounterClassCode` VARCHAR(191), + `encounterClassDisplay` VARCHAR(191), + `typeText` VARCHAR(191), + `subjectRef` VARCHAR(191) NOT NULL, + `periodStart` VARCHAR(191), + `periodEnd` VARCHAR(191), + PRIMARY KEY(`id`) +); + + diff --git a/packages/ballerina-extension/test/data/healthcare_sample/patient_api_config.bal b/packages/ballerina-extension/test/data/healthcare_sample/patient_api_config.bal new file mode 100644 index 00000000000..147ee970c8c --- /dev/null +++ b/packages/ballerina-extension/test/data/healthcare_sample/patient_api_config.bal @@ -0,0 +1,128 @@ +// Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// +// AUTO-GENERATED FILE. +// +// This file is auto-generated by Ballerina. +// Developers are allowed to modify this file as per the requirement. + +import ballerinax/health.fhir.r4; + +final r4:ResourceAPIConfig patientApiConfig = { + resourceType: "Patient", + profiles: [ + "http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient" + + ], + defaultProfile: (), + searchParameters: [ + { + name: "family", + active: true, + information: { + description: "**A portion of the family name of the patient** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-patient-family" + } + }, + + { + name: "identifier", + active: true, + information: { + description: "**A patient identifier** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-patient-identifier" + } + }, + + { + name: "_id", + active: true, + information: { + description: "**Logical id of this artifact** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-patient-id" + } + }, + + { + name: "gender", + active: true, + information: { + description: "**Gender of the patient** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-patient-gender" + } + }, + + { + name: "name", + active: true, + information: { + description: "**A server defined search that may match any of the string fields in the HumanName, including family, give, prefix, suffix, suffix, and/or text** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-patient-name" + } + }, + + { + name: "birthdate", + active: true, + information: { + description: "**The patient's date of birth** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-patient-birthdate" + } + }, + + { + name: "ethnicity", + active: true, + information: { + description: "Returns patients with an ethnicity extension matching the specified code.", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-ethnicity" + } + }, + + { + name: "race", + active: true, + information: { + description: "Returns patients with a race extension matching the specified code.", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-race" + } + }, + + { + name: "given", + active: true, + information: { + description: "**A portion of the given name of the patient** **NOTE**: This US Core SearchParameter definition extends the usage context of the[Conformance expectation extension](http://hl7.org/fhir/R4/extension-capabilitystatement-expectation.html) - multipleAnd - multipleOr - comparator - modifier - chain", + builtin: false, + documentation: "http://hl7.org/fhir/us/core/SearchParameter/us-core-patient-given" + } + } + + ], + operations: [ + + ], + serverConfig: (), + authzConfig: () +}; diff --git a/packages/ballerina-extension/test/data/healthcare_sample/persist/model.bal b/packages/ballerina-extension/test/data/healthcare_sample/persist/model.bal new file mode 100644 index 00000000000..0b39faf6d95 --- /dev/null +++ b/packages/ballerina-extension/test/data/healthcare_sample/persist/model.bal @@ -0,0 +1,60 @@ +// Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/persist as _; + +public type PatientData record {| + // FHIR: Patient.id + readonly string id; + + // FHIR: Patient.name[0].text + string? name; + + // FHIR: Patient.gender + string? gender; + + // FHIR: Patient.birthDate + string? birthDate; +|}; + +public type EncounterData record {| + // FHIR: Encounter.id + readonly string id; + + // FHIR: Encounter.status + string status; // required + + // FHIR: Encounter.class.system + string? encounterClassSystem; + + // FHIR: Encounter.class.code + string? encounterClassCode; + + // FHIR: Encounter.class.display + string? encounterClassDisplay; + + // FHIR: Encounter.type[0].text + string? typeText; + + // FHIR: Encounter.subject.reference + string subjectRef; // required + + // FHIR: Encounter.period.start + string? periodStart; + + // FHIR: Encounter.period.end + string? periodEnd; +|}; diff --git a/packages/ballerina-extension/test/data/healthcare_sample/service.bal b/packages/ballerina-extension/test/data/healthcare_sample/service.bal new file mode 100644 index 00000000000..737b94740e9 --- /dev/null +++ b/packages/ballerina-extension/test/data/healthcare_sample/service.bal @@ -0,0 +1,210 @@ +// Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// +// AUTO-GENERATED FILE. +// +// This file is auto-generated by Ballerina. +// Developers are allowed to modify this file as per the requirement. + +import healthcare_sample.db; + +import ballerina/http; +import ballerina/log; +import ballerina/persist; +import ballerina/sql; +import ballerinax/health.fhir.r4; +import ballerinax/health.fhirr4; +import ballerinax/health.fhir.r4.uscore700; + +configurable string SERVER_BASE_URL = ?; + +final db:Client dbClient; + +// Initialize database client +function init() returns error? { + dbClient = check new (); +} + +# Generic types to wrap all implemented profiles for each resource. +# Add required profile types here. +public type Patient uscore700:USCorePatientProfile; + +public type Encounter uscore700:USCoreEncounterProfile; + +listener http:Listener httpListener = http:getDefaultListener(); + +# initialize source system endpoints here + +service http:Service /fhir/r4/metadata on httpListener { + + # The capability statement is a key part of the overall conformance framework in FHIR. It is used as a statement of the + # features of actual software, or of a set of rules for an application to provide. This statement connects to all the + # detailed statements of functionality, such as StructureDefinitions and ValueSets. This composite statement of application + # capability may be used for system compatibility testing, code generation, or as the basis for a conformance assessment. + # For further information https://hl7.org/fhir/capabilitystatement.html + # + return - capability statement as a json + isolated resource function get .() returns r4:CapabilityStatement|error { + return check r4:generateFHIRCapabilityStatement(); + } +} + +# Patient API # +service /fhir/r4/Patient on new fhirr4:Listener(config = patientApiConfig) { + + // Read the current state of single resource based on its id. + isolated resource function get [string id](r4:FHIRContext fhirContext) returns Patient|r4:OperationOutcome|r4:FHIRError { + db:PatientDataOptionalized|persist:Error response = dbClient->/patientdata/[id](); + if response is persist:Error { + return r4:createFHIRError(string `Resource not found: ${id}`, + r4:ERROR, + r4:SECURITY_UNKNOWN, + cause = response, + httpStatusCode = http:STATUS_NOT_FOUND); + } else { + return mapCustomPatientToFHIR(response); + } + } + + // Read the state of a specific version of a resource based on its id. + isolated resource function get [string id]/_history/[string vid](r4:FHIRContext fhirContext) returns Patient|r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Search for resources based on a set of criteria. + isolated resource function get .(r4:FHIRContext fhirContext) returns r4:Bundle|r4:OperationOutcome|r4:FHIRError { + + // Example of a search parameter implementation for "name" parameter. + r4:StringSearchParameter[]|r4:FHIRTypeError? nameSearchParameter = fhirContext.getStringSearchParameter("name"); + if nameSearchParameter is r4:StringSearchParameter[] { + string name = nameSearchParameter[0].value; + sql:ParameterizedQuery query = `name = ${name}`; + stream streamResult = dbClient->/patientdata(targetType = db:PatientDataOptionalized, whereClause = query); + do { + r4:BundleEntry[] entries = check from var patientData in streamResult + select { + 'resource: mapCustomPatientToFHIR(patientData) + }; + r4:Bundle bundle = { + entry: [...entries], + 'type: "searchset" + }; + return bundle; + } on fail var e { + log:printError("Error occurred while processing the stream", e); + return r4:createFHIRError("Something went wrong while processing the data", + r4:ERROR, + r4:SECURITY_UNKNOWN, + cause = e, + httpStatusCode = http:STATUS_INTERNAL_SERVER_ERROR); + } + } + + return r4:createFHIRError("Search query is not supported", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Create a new resource. + isolated resource function post .(r4:FHIRContext fhirContext, Patient patient) returns r4:FHIRError|http:Response { + db:PatientDataInsert mapFHIRTodbDataResult = mapFhirToCustomPatient(patient); + string[]|persist:Error dbResponse = dbClient->/patientdata.post([mapFHIRTodbDataResult]); + if dbResponse is persist:Error { + // These errors will be converted to the OperationOutcome from the FHIR service layer. + return r4:createFHIRError("Something went wrong while inserting data", + r4:ERROR, + r4:SECURITY_UNKNOWN, + cause = dbResponse, + httpStatusCode = http:STATUS_INTERNAL_SERVER_ERROR); + } + http:Response response = new; + response.statusCode = http:STATUS_CREATED; + response.addHeader(http:LOCATION, string `${SERVER_BASE_URL}/Patient/${dbResponse[0]}`); + return response; + } + + // Update the current state of a resource completely. + isolated resource function put [string id](r4:FHIRContext fhirContext, Patient patient) returns Patient|r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Update the current state of a resource partially. + isolated resource function patch [string id](r4:FHIRContext fhirContext, json patch) returns Patient|r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Delete a resource. + isolated resource function delete [string id](r4:FHIRContext fhirContext) returns r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Retrieve the update history for a particular resource. + isolated resource function get [string id]/_history(r4:FHIRContext fhirContext) returns r4:Bundle|r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Retrieve the update history for all resources. + isolated resource function get _history(r4:FHIRContext fhirContext) returns r4:Bundle|r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } +} + +# Encounter API # +service /fhir/r4/Encounter on new fhirr4:Listener(config = encounterApiConfig) { + + // Read the current state of single resource based on its id. + isolated resource function get [string id](r4:FHIRContext fhirContext) returns Encounter|r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Read the state of a specific version of a resource based on its id. + isolated resource function get [string id]/_history/[string vid](r4:FHIRContext fhirContext) returns Encounter|r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Search for resources based on a set of criteria. + isolated resource function get .(r4:FHIRContext fhirContext) returns r4:Bundle|r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Create a new resource. + isolated resource function post .(r4:FHIRContext fhirContext, Encounter encounter) returns Encounter|r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Update the current state of a resource completely. + isolated resource function put [string id](r4:FHIRContext fhirContext, Encounter encounter) returns Encounter|r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Update the current state of a resource partially. + isolated resource function patch [string id](r4:FHIRContext fhirContext, json patch) returns Encounter|r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Delete a resource. + isolated resource function delete [string id](r4:FHIRContext fhirContext) returns r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Retrieve the update history for a particular resource. + isolated resource function get [string id]/_history(r4:FHIRContext fhirContext) returns r4:Bundle|r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } + + // Retrieve the update history for all resources. + isolated resource function get _history(r4:FHIRContext fhirContext) returns r4:Bundle|r4:OperationOutcome|r4:FHIRError { + return r4:createFHIRError("Not implemented", r4:ERROR, r4:INFORMATIONAL, httpStatusCode = http:STATUS_NOT_IMPLEMENTED); + } +} + diff --git a/packages/ballerina-extension/test/data/hotel_reservation/Ballerina.toml b/packages/ballerina-extension/test/data/hotel_reservation/Ballerina.toml new file mode 100644 index 00000000000..aaee305a2df --- /dev/null +++ b/packages/ballerina-extension/test/data/hotel_reservation/Ballerina.toml @@ -0,0 +1,8 @@ +[package] +org = "wso2" +name = "service" +version = "0.1.0" +distribution = "2201.8.4" + +[build-options] +observabilityIncluded = true diff --git a/packages/ballerina-extension/test/data/hotel_reservation/service.bal b/packages/ballerina-extension/test/data/hotel_reservation/service.bal new file mode 100644 index 00000000000..e585a0fbfce --- /dev/null +++ b/packages/ballerina-extension/test/data/hotel_reservation/service.bal @@ -0,0 +1,48 @@ +import ballerina/http; + +final Room[] rooms = getAllRooms(); +table key(id) roomReservations = table []; + +service /reservations on new http:Listener(9090) { + + resource function post .(NewReservationRequest payload) returns Reservation|NewReservationError|error? { + // Complete the implementation to check whether a room is available for the given dates + // Use getAvailableRoom function to check whether a room is available + // And create a new reservation if room is available + } + + resource function put [int reservationId](UpdateReservationRequest payload) returns Reservation|UpdateReservationError|error { + Reservation? reservation = roomReservations[reservationId]; + if (reservation is ()) { + return {body: "Reservation not found"}; + } + Room? room = check getAvailableRoom(payload.checkinDate, payload.checkoutDate, reservation.room.'type.name); + if (room is ()) { + return {body: "No rooms available for the given dates"}; + } + reservation.room = room; + reservation.checkinDate = payload.checkinDate; + reservation.checkoutDate = payload.checkoutDate; + sendNotificationForReservation(reservation, "Updated"); + return reservation; + } + + resource function delete [int reservationId]() returns http:NotFound|http:Ok { + if (roomReservations.hasKey(reservationId)) { + _ = roomReservations.remove(reservationId); + return http:OK; + } else { + return http:NOT_FOUND; + } + } + + resource function get users/[string userId]() returns Reservation[] { + return from Reservation r in roomReservations + where r.user.id == userId + select r; + } + + resource function get roomTypes(string checkinDate, string checkoutDate, int guestCapacity) returns RoomType[]|error { + return getAvailableRoomTypes(checkinDate, checkoutDate, guestCapacity); + } +} diff --git a/packages/ballerina-extension/test/data/hotel_reservation/tests/Config.toml b/packages/ballerina-extension/test/data/hotel_reservation/tests/Config.toml new file mode 100644 index 00000000000..5c8a7506ce0 --- /dev/null +++ b/packages/ballerina-extension/test/data/hotel_reservation/tests/Config.toml @@ -0,0 +1,5 @@ +[user] +id="123" +name="wso2" +email="" +mobileNumber="" diff --git a/packages/ballerina-extension/test/data/hotel_reservation/tests/service_test.bal b/packages/ballerina-extension/test/data/hotel_reservation/tests/service_test.bal new file mode 100644 index 00000000000..59931858e8d --- /dev/null +++ b/packages/ballerina-extension/test/data/hotel_reservation/tests/service_test.bal @@ -0,0 +1,15 @@ +import ballerina/http; +import ballerina/test; + +http:Client testClient = check new ("http://localhost:9090"); + +configurable User user = ?; + +@test:Config {} +function testReservation() returns error? { + // Create a reservation + anydata reservationRequest = {checkinDate: "2024-02-19T14:00:00Z", checkoutDate: "2024-02-20T10:00:00Z", rate: 100, user: user, roomType: "Family"}; + Reservation reservation = check testClient->post("/reservations", reservationRequest); + test:assertEquals(reservation.room.number, 303); +} + diff --git a/packages/ballerina-extension/test/data/hotel_reservation/types.bal b/packages/ballerina-extension/test/data/hotel_reservation/types.bal new file mode 100644 index 00000000000..553a5900d0a --- /dev/null +++ b/packages/ballerina-extension/test/data/hotel_reservation/types.bal @@ -0,0 +1,51 @@ +import ballerina/http; + +public type Reservation record {| + readonly int id; + Room room; + string checkinDate; + string checkoutDate; + User user; +|}; + +public type Room record {| + readonly int number; + RoomType 'type; +|}; + +public type RoomType record { + int id; + string name; + int guestCapacity; + decimal price; +}; + +public type User record { + string id; + string name; + string email; + string mobileNumber; +}; + +type NewReservationRequest record { + string checkinDate; + string checkoutDate; + int rate; + User user; + string roomType; +}; + +type UpdateReservationRequest record { + string checkinDate; + string checkoutDate; +}; + +type NewReservationError record {| + *http:NotFound; + string body; +|}; + +type UpdateReservationError record {| + *http:NotFound; + string body; +|}; diff --git a/packages/ballerina-extension/test/data/hotel_reservation/utils.bal b/packages/ballerina-extension/test/data/hotel_reservation/utils.bal new file mode 100644 index 00000000000..ccb3fedcd7d --- /dev/null +++ b/packages/ballerina-extension/test/data/hotel_reservation/utils.bal @@ -0,0 +1,317 @@ +import ballerina/log; +import ballerina/time; + +import wso2/choreo.sendemail; +import wso2/choreo.sendsms; + +sendsms:Client smsClient = check new (); +sendemail:Client emailClient = check new (); + +# This function provides the available room types for a given date range and guest capacity +# +# + checkinDate - checkin date +# + checkoutDate - checkout date +# + guestCapacity - guest capacity +# + return - returns the available room types +function getAvailableRoomTypes(string checkinDate, string checkoutDate, int guestCapacity) returns RoomType[]|error { + // This code will use ballerina query to extract the available room types + table key(number) allocatedRooms = check getAllocatedRooms(checkinDate, checkoutDate); + return from Room r in rooms + where r.'type.guestCapacity >= guestCapacity && !allocatedRooms.hasKey(r.number) + let var t = r.'type + group by t + select t; +} + +# This function provides the available rooms for a given date range and room type +# +# + checkinDate - checkin date +# + checkoutDate - checkout date +# + roomType - room type +# + return - returns the available rooms +function getAvailableRoom(string checkinDate, string checkoutDate, string roomType) returns Room?|error { + table key(number) allocatedRooms = check getAllocatedRooms(checkinDate, checkoutDate); + // This code use for each loop to extract the available room. + foreach Room r in rooms { + if r.'type.name == roomType && !allocatedRooms.hasKey(r.number) { + return r; + } + } + return; +} + +# This function provides the allocated rooms for a given date range +# +# + checkinDate - checkin date +# + checkoutDate - checkout date +# + return - returns the allocated rooms +function getAllocatedRooms(string checkinDate, string checkoutDate) returns table key(number)|error { + time:Utc userCheckinUTC = check time:utcFromString(checkinDate); + time:Utc userCheckoutUTC = check time:utcFromString(checkoutDate); + // This code will use ballerina query to extract the allocated rooms + return table key(number) from Reservation r in roomReservations + let time:Utc rCheckin = check time:utcFromString(r.checkinDate) + let time:Utc rCheckout = check time:utcFromString(r.checkoutDate) + where userCheckinUTC <= rCheckin && userCheckoutUTC >= rCheckout + select r.room; +} + +# This function send notification for a reservation +# +# + reservation - reservation id +# + action - action +function sendNotificationForReservation(Reservation reservation, string action) { + string message = getSmsContent(reservation); + string emailSubject = getEmailSubject(reservation, action); + string emailBody = getEmailContent(reservation); + string|error sendEmal = trap emailClient->sendEmail(reservation.user.email, emailSubject, emailBody); + if (sendEmal is error) { + log:printError("Error sending Email: ", sendEmal); + } + string|error sendSms = trap smsClient->sendSms(reservation.user.mobileNumber, message); + if (sendSms is error) { + log:printError("Error sending SMS: ", sendSms); + } +} + +function getEmailSubject(Reservation reservation, string action) returns string => string `Reservation ${action}: ${reservation.id}`; + +function getEmailContent(Reservation reservation) returns string => + let Room room = reservation.room in + string `Dear ${reservation.user.name}, + + We are pleased to confirm your reservation at our hotel. + + Reservation Details + Reservation Number: ${reservation.id} + Reservation Checkin Date: ${reservation.checkinDate} + Reservation Checkout Date: ${reservation.checkoutDate} + + Room Details + Number: ${room.number} + Type: ${room.'type.name} + + Thanks, + Reservation Team`; + +function getSmsContent(Reservation reservation) returns string => + string `We are pleased to confirm your reservation at our hotel with Reservation Number: ${reservation.id}`; + +function getAllRooms() returns Room[] => [ + { + "number": 101, + "type": { + "id": 0, + "name": "Single", + "guestCapacity": 1, + "price": 80 + } + }, + { + "number": 102, + "type": { + "id": 0, + "name": "Single", + "guestCapacity": 1, + "price": 80 + } + }, + { + "number": 103, + "type": { + "id": 0, + "name": "Single", + "guestCapacity": 1, + "price": 80 + } + }, + { + "number": 104, + "type": { + "id": 0, + "name": "Single", + "guestCapacity": 1, + "price": 80 + } + }, + { + "number": 105, + "type": { + "id": 1, + "name": "Double", + "guestCapacity": 2, + "price": 120 + } + }, + { + "number": 106, + "type": { + "id": 1, + "name": "Double", + "guestCapacity": 2, + "price": 120 + } + }, + { + "number": 201, + "type": { + "id": 0, + "name": "Single", + "guestCapacity": 1, + "price": 80 + } + }, + { + "number": 202, + "type": { + "id": 0, + "name": "Single", + "guestCapacity": 1, + "price": 80 + } + }, + { + "number": 203, + "type": { + "id": 0, + "name": "Single", + "guestCapacity": 1, + "price": 80 + } + }, + { + "number": 204, + "type": { + "id": 1, + "name": "Double", + "guestCapacity": 2, + "price": 120 + } + }, + { + "number": 205, + "type": { + "id": 1, + "name": "Double", + "guestCapacity": 2, + "price": 120 + } + }, + { + "number": 206, + "type": { + "id": 1, + "name": "Double", + "guestCapacity": 2, + "price": 120 + } + }, + { + "number": 301, + "type": { + "id": 1, + "name": "Double", + "guestCapacity": 2, + "price": 120 + } + }, + { + "number": 302, + "type": { + "id": 1, + "name": "Double", + "guestCapacity": 2, + "price": 120 + } + }, + { + "number": 303, + "type": { + "id": 2, + "name": "Family", + "guestCapacity": 4, + "price": 200 + } + }, + { + "number": 304, + "type": { + "id": 3, + "name": "Suite", + "guestCapacity": 4, + "price": 300 + } + }, + { + "number": 305, + "type": { + "id": 0, + "name": "Single", + "guestCapacity": 1, + "price": 80 + } + }, + { + "number": 306, + "type": { + "id": 1, + "name": "Double", + "guestCapacity": 2, + "price": 120 + } + }, + { + "number": 401, + "type": { + "id": 0, + "name": "Single", + "guestCapacity": 1, + "price": 80 + } + }, + { + "number": 402, + "type": { + "id": 1, + "name": "Double", + "guestCapacity": 2, + "price": 120 + } + }, + { + "number": 403, + "type": { + "id": 2, + "name": "Family", + "guestCapacity": 4, + "price": 200 + } + }, + { + "number": 404, + "type": { + "id": 3, + "name": "Suite", + "guestCapacity": 4, + "price": 300 + } + }, + { + "number": 405, + "type": { + "id": 0, + "name": "Single", + "guestCapacity": 1, + "price": 80 + } + }, + { + "number": 406, + "type": { + "id": 3, + "name": "Suite", + "guestCapacity": 4, + "price": 300 + } + } +]; diff --git a/packages/ballerina-extension/test/data/order_management_system/Ballerina.toml b/packages/ballerina-extension/test/data/order_management_system/Ballerina.toml new file mode 100644 index 00000000000..86e131d1a80 --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/Ballerina.toml @@ -0,0 +1,2 @@ +[workspace] +packages = ["order_service", "order_utils"] diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/Ballerina.toml b/packages/ballerina-extension/test/data/order_management_system/order_service/Ballerina.toml new file mode 100644 index 00000000000..348708bb506 --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/Ballerina.toml @@ -0,0 +1,13 @@ +[package] +org = "wso2" +name = "order_service" +version = "0.1.0" +distribution = "2201.13.2" + +[[dependency]] +org = "yasithrashan" +name = "order_utils" +version = "0.1.0" + +[build-options] +observabilityIncluded = true diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/configurations.bal b/packages/ballerina-extension/test/data/order_management_system/order_service/configurations.bal new file mode 100644 index 00000000000..aac2f2cbdb6 --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/configurations.bal @@ -0,0 +1 @@ +configurable int SERVICE_PORT = 9090; diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/functions.bal b/packages/ballerina-extension/test/data/order_management_system/order_service/functions.bal new file mode 100644 index 00000000000..9af1a1b162e --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/functions.bal @@ -0,0 +1,100 @@ +import ballerina/lang.value as value; +import ballerina/log; +import ballerina/sql; + +import wso2/order_service.db; +import wso2/order_service.messaging; +import wso2/order_utils; + +public function createNewOrder(OrderCreatePayload payload) returns OrderCreationResponse|error { + string orderId = order_utils:generateId(); + decimal totalAmount = calculateTotal(payload.orderLines); + + decimal mockUnitPrice = 99.99; + OrderLine[] linesWithPrices = from var line in payload.orderLines + select { + lineId: order_utils:generateId(), + sku: line.sku, + quantity: line.quantity, + unitPrice: mockUnitPrice, + lineTotal: order_utils:calculateLineTotal(mockUnitPrice, line.quantity) + }; + + db:OrderInsertRecord dbRecord = { + orderId: orderId, + customerId: payload.customerId, + status: "PENDING", + createdAt: order_utils:getCurrentTimestamp(), + totalAmount: totalAmount, + currency: payload.currency, + shippingAddress: value:toJsonString(value:toJson(payload.shippingAddress)), + billingAddress: value:toJsonString(value:toJson(payload.billingAddress)), + orderLines: value:toJsonString(value:toJson(linesWithPrices)) + }; + + sql:ExecutionResult|sql:Error dbResult = db:insertOrder(dbRecord); + if dbResult is sql:Error { + log:printError("Database error on initial order insert", dbResult); + return dbResult; + } + + messaging:OrderCreatedEvent event = { + eventId: order_utils:generateId(), + timestamp: order_utils:getCurrentTimestamp(), + data: { + orderId: orderId, + customerId: payload.customerId, + currency: payload.currency, + totalAmount: totalAmount, + orderLines: from var line in payload.orderLines + select {sku: line.sku, quantity: line.quantity}, + paymentInfo: { + paymentMethodToken: payload.paymentInfo.paymentMethodToken, + amount: payload.paymentInfo.amount + } + } + }; + + error? kafkaResult = messaging:publishOrderEvent(event); + if kafkaResult is error { + log:printError("Kafka publish error", kafkaResult); + return kafkaResult; + } + + return {orderId: orderId}; +} + +public function getOrderById(string orderId) returns Order|db:OrderNotFoundError|error { + db:OrderDbRow|db:OrderNotFoundError|error dbResult = db:getOrderById(orderId); + + if dbResult is error { + return dbResult; + } + + db:OrderDbRow row = dbResult; + Address shippingAddress = check row.shippingAddress.cloneWithType(Address); + Address billingAddress = check row.billingAddress.cloneWithType(Address); + OrderLine[] orderLines = check row.orderLines.cloneWithType(); + OrderStatus orderStatus = check value:ensureType(row.status, OrderStatus); + + return { + orderId: row.orderId, + customerId: row.customerId, + status: orderStatus, + createdAt: row.createdAt, + totalAmount: row.totalAmount, + currency: row.currency, + shippingAddress: shippingAddress, + billingAddress: billingAddress, + orderLines: orderLines, + payments: [], + shipments: [] + }; +} + +function calculateTotal(OrderLinePayload[] lines) returns decimal { + decimal mockUnitPrice = 99.99; + return lines.reduce(function(decimal acc, OrderLinePayload line) returns decimal { + return acc + order_utils:calculateLineTotal(mockUnitPrice, line.quantity); + }, 0.0d); +} diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/main.bal b/packages/ballerina-extension/test/data/order_management_system/order_service/main.bal new file mode 100644 index 00000000000..4a5b8eeb8b5 --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/main.bal @@ -0,0 +1,15 @@ +import wso2/order_service.db; +import wso2/order_service.messaging; +import ballerina/log; +import ballerina/lang.runtime as runtime; + +public function main() { + log:printInfo("Order Service starting up...", port = SERVICE_PORT); + + runtime:onGracefulStop(function () { + log:printInfo("Shutting down Order Service..."); + checkpanic db:closeClient(); + checkpanic messaging:closeProducer(); + log:printInfo("Shutdown complete."); + }); +} diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_client.bal b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_client.bal new file mode 100644 index 00000000000..f6aea87a606 --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_client.bal @@ -0,0 +1,13 @@ +import ballerinax/postgresql; + +public final postgresql:Client dbClient = check new ( + host = host, + port = port, + username = username, + password = password, + database = database +); + +public function closeClient() returns error? { + return dbClient.close(); +} diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_config.bal b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_config.bal new file mode 100644 index 00000000000..483ef0cf5dc --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_config.bal @@ -0,0 +1,5 @@ +configurable string host = "localhost"; +configurable int port = 5432; +configurable string username = "user"; +configurable string password = "password"; +configurable string database = "order_db"; diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_operations.bal b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_operations.bal new file mode 100644 index 00000000000..50fbc94f85a --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_operations.bal @@ -0,0 +1,21 @@ +import ballerina/sql; +import ballerina/log; + +public function insertOrder(OrderInsertRecord rec) returns sql:ExecutionResult|sql:Error { + sql:ParameterizedQuery q = `INSERT INTO orders (orderId, customerId, status, createdAt, totalAmount, currency, shippingAddress, billingAddress, orderLines) VALUES (${rec.orderId}, ${rec.customerId}, ${rec.status}, ${rec.createdAt}, ${rec.totalAmount}, ${rec.currency}, ${rec.shippingAddress}, ${rec.billingAddress}, ${rec.orderLines});`; + return dbClient->execute(q); +} + +public function getOrderById(string orderId) returns OrderDbRow|OrderNotFoundError|error { + sql:ParameterizedQuery q = `SELECT orderId, customerId, status, createdAt, totalAmount, currency, shippingAddress, billingAddress, orderLines FROM orders WHERE orderId = ${orderId};`; + + stream resultStream = dbClient->query(q); + record {|OrderDbRow value;|}? row = check resultStream.next(); + check resultStream.close(); + + if row is () { + log:printWarn("Order not found in database", orderId = orderId); + return error OrderNotFoundError("Order not found: " + orderId); + } + return row.value; +} diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_types.bal b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_types.bal new file mode 100644 index 00000000000..de2fba5a8de --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/db/db_types.bal @@ -0,0 +1,25 @@ +public type OrderDbRow record {| + string orderId; + string customerId; + string status; + string createdAt; + decimal totalAmount; + string currency; + json shippingAddress; + json billingAddress; + json orderLines; +|}; + +public type OrderInsertRecord record {| + string orderId; + string customerId; + string status; + string createdAt; + decimal totalAmount; + string currency; + string shippingAddress; + string billingAddress; + string orderLines; +|}; + +public type OrderNotFoundError distinct error; diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_config.bal b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_config.bal new file mode 100644 index 00000000000..a5e90c13f07 --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_config.bal @@ -0,0 +1,2 @@ +configurable string brokerUrl = "localhost:9092"; +configurable string orderEventsTopic = "order.events"; diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_operations.bal b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_operations.bal new file mode 100644 index 00000000000..835345017ec --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_operations.bal @@ -0,0 +1,14 @@ +import ballerina/lang.value as value; +import ballerina/log; + +public function publishOrderEvent(OrderCreatedEvent eventPayload) returns error? { + string eventString = value:toJsonString(value:toJson(eventPayload)); + + check kafkaProducer->send({ + topic: orderEventsTopic, + key: eventPayload.data.orderId, + value: eventString.toBytes() + }); + check kafkaProducer->'flush(); + log:printInfo("Published OrderCreated event to Kafka", orderId = eventPayload.data.orderId); +} diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_producer.bal b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_producer.bal new file mode 100644 index 00000000000..1e0b47a9f49 --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_producer.bal @@ -0,0 +1,9 @@ +import ballerinax/kafka; + +public final kafka:Producer kafkaProducer = check new ( + bootstrapServers = [brokerUrl] +); + +public function closeProducer() returns error? { + return kafkaProducer->close(); +} diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_types.bal b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_types.bal new file mode 100644 index 00000000000..0fb8ba51e1b --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/modules/messaging/kafka_types.bal @@ -0,0 +1,25 @@ +public type OrderCreatedEvent record {| + string eventId; + string eventType = "OrderCreated"; + string timestamp; + OrderCreatedEventData data; +|}; + +public type OrderCreatedEventData record {| + string orderId; + string customerId; + string currency; + decimal totalAmount; + OrderLineEvent[] orderLines; + PaymentInfoEvent paymentInfo; +|}; + +public type OrderLineEvent record {| + string sku; + int quantity; +|}; + +public type PaymentInfoEvent record {| + string paymentMethodToken; + decimal amount; +|}; diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/service.bal b/packages/ballerina-extension/test/data/order_management_system/order_service/service.bal new file mode 100644 index 00000000000..6d36b5232e7 --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/service.bal @@ -0,0 +1,50 @@ +import ballerina/http; +import ballerina/log; + +import wso2/order_service.db; + +public type OrderCreationResponse record {| + string orderId; + string status = "PENDING"; + string message = "Order received and is being processed."; +|}; + +@http:ServiceConfig { + cors: { + allowOrigins: ["https://grc.com"], + allowMethods: ["GET", "POST"] + } +} +service /v1 on new http:Listener(SERVICE_PORT) { + + resource function post orders(@http:Payload OrderCreatePayload payload) returns OrderCreationResponse|http:InternalServerError|http:BadRequest { + if payload.orderLines.length() == 0 { + log:printWarn("Create order attempt with no order lines", customerId = payload.customerId); + return {body: {message: "Order must contain at least one line item."}}; + } + + var result = createNewOrder(payload); + + if result is OrderCreationResponse { + log:printInfo("Order creation process initiated", orderId = result.orderId); + return result; + } else { + log:printError("Failed to initiate order creation", result); + return {body: {message: "An internal error occurred while creating the order."}}; + } + } + + resource function get orders/[string orderId]() returns Order|http:NotFound|http:InternalServerError { + var result = getOrderById(orderId); + + if result is Order { + return result; + } else if result is db:OrderNotFoundError { + log:printWarn("Order not found", orderId = orderId); + return {body: {message: string `Order with ID ${orderId} not found.`}}; + } else { + log:printError("Unexpected error fetching order", result, orderId = orderId); + return {body: {message: "An internal error occurred."}}; + } + } +} diff --git a/packages/ballerina-extension/test/data/order_management_system/order_service/types.bal b/packages/ballerina-extension/test/data/order_management_system/order_service/types.bal new file mode 100644 index 00000000000..d03e1179191 --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_service/types.bal @@ -0,0 +1,64 @@ +public type Order record { + string orderId; + string customerId; + OrderStatus status; + string createdAt; + decimal totalAmount; + string currency; + Address shippingAddress; + Address billingAddress; + OrderLine[] orderLines; + Payment[] payments; + Shipment[] shipments; +}; + +public type OrderCreatePayload record {| + string customerId; + string currency; + Address shippingAddress; + Address billingAddress; + OrderLinePayload[] orderLines; + PaymentInfo paymentInfo; +|}; + +public type OrderLinePayload record {| + string sku; + int quantity; +|}; + +public type OrderLine record {| + string lineId; + string sku; + int quantity; + decimal unitPrice; + decimal lineTotal; +|}; + +public type Address record {| + string line1; + string? line2; + string city; + string state; + string zipCode; + string country; +|}; + +public type Payment record {| + string paymentId; + string status; + decimal amount; +|}; + +public type PaymentInfo record {| + string paymentMethodToken; + decimal amount; +|}; + +public type Shipment record {| + string shipmentId; + string trackingNumber; + string carrier; + string status; +|}; + +public type OrderStatus "PENDING"|"CONFIRMED"|"AWAITING_PAYMENT"|"FULFILLING"|"SHIPPED"|"DELIVERED"|"CANCELLED"|"RETURNED"|"FAILED"; diff --git a/packages/ballerina-extension/test/data/order_management_system/order_utils/Ballerina.toml b/packages/ballerina-extension/test/data/order_management_system/order_utils/Ballerina.toml new file mode 100644 index 00000000000..aa411a68a3e --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_utils/Ballerina.toml @@ -0,0 +1,5 @@ +[package] +org = "wso2" +name = "order_utils" +version = "0.1.0" +distribution = "2201.13.2" diff --git a/packages/ballerina-extension/test/data/order_management_system/order_utils/utils.bal b/packages/ballerina-extension/test/data/order_management_system/order_utils/utils.bal new file mode 100644 index 00000000000..b703997cc90 --- /dev/null +++ b/packages/ballerina-extension/test/data/order_management_system/order_utils/utils.bal @@ -0,0 +1,25 @@ +import ballerina/uuid; +import ballerina/time; + +# Generates a new random UUID v4 string. +# +# + return - UUID string +public function generateId() returns string { + return uuid:createType4AsString(); +} + +# Returns the current UTC time as an ISO 8601 string. +# +# + return - timestamp string +public function getCurrentTimestamp() returns string { + return time:utcToString(time:utcNow()); +} + +# Calculates the total price for a line item. +# +# + unitPrice - price per unit +# + quantity - number of units +# + return - line total +public function calculateLineTotal(decimal unitPrice, int quantity) returns decimal { + return unitPrice * quantity; +} diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration/Ballerina.toml b/packages/ballerina-extension/test/data/salesforce_slack_integration/Ballerina.toml new file mode 100644 index 00000000000..0ba6e66eb73 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration/Ballerina.toml @@ -0,0 +1,7 @@ +[package] +org = "wso2" +name = "salesforce_slack_integration" +version = "0.1.0" +title = "salesforce_slack_integration" +distribution = "2201.13.2" + diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration/agents.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration/agents.bal new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration/config.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration/config.bal new file mode 100644 index 00000000000..a0ec5f25431 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration/config.bal @@ -0,0 +1,20 @@ +configurable record { + string baseUrl; + string clientId; + string clientSecret; + string refreshToken; + string refreshUrl; +} salesforceConfig = ?; + +configurable record { + string token; + string defaultChannel; +} slackConfig = ?; + +configurable record { + string messageTemplate = "🎉 Lead Converted!\n*Lead:* {{lead.name}}\n*Company:* {{lead.company}}\n*Owner:* {{lead.owner}}"; + string[] filterLeadSources = []; + string[] filterOwnerIds = []; + TeamChannelMapping[] teamChannelMappings = []; + boolean includeConversionDetails = true; +} notificationConfig = ?; diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration/connections.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration/connections.bal new file mode 100644 index 00000000000..c942bc08b50 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration/connections.bal @@ -0,0 +1,26 @@ +import ballerina/http; +import ballerinax/salesforce; +import ballerinax/slack; + +// Salesforce client initialization +final salesforce:Client salesforceClient = check new ({ + baseUrl: salesforceConfig.baseUrl, + auth: { + clientId: salesforceConfig.clientId, + clientSecret: salesforceConfig.clientSecret, + refreshToken: salesforceConfig.refreshToken, + refreshUrl: salesforceConfig.refreshUrl + } +}); + +// Slack client initialization +final slack:Client slackClient = check new ({ + auth: { + token: slackConfig.token + } +}); + +// Raw Slack HTTP client for APIs with broken connector type bindings +final http:Client slackHttpClient = check new ("https://slack.com", { + auth: {token: slackConfig.token} +}); diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration/data_mappings.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration/data_mappings.bal new file mode 100644 index 00000000000..6ba67d002c7 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration/data_mappings.bal @@ -0,0 +1,37 @@ +import ballerina/time; + +// Build lead conversion details from Salesforce data +function buildLeadConversionDetails( + Lead lead, + LeadOwner owner, + ConvertedAccount account, + ConvertedContact contact, + ConvertedOpportunity opportunity +) returns LeadConversionDetails|error { + + string convertedDate = lead.ConvertedDate ?: lead.CreatedDate; + decimal lifecycleDuration = check calculateLifecycleDuration(lead.CreatedDate, convertedDate); + + time:Utc convertedUtc = check time:utcFromString(toRfc3339(convertedDate)); + + string instanceUrl = getSalesforceInstanceUrl(); + string opportunityLink = instanceUrl + "/" + opportunity.Id; + + LeadConversionDetails details = { + leadId: lead.Id, + leadName: lead.Name, + company: lead.Company, + ownerName: owner.Name, + ownerId: owner.Id, + leadSource: lead.LeadSource, + accountName: account.Name, + contactName: contact.Name, + opportunityName: opportunity.Name, + opportunityId: opportunity.Id, + opportunityLink: opportunityLink, + lifecycleDurationDays: lifecycleDuration, + convertedTime: convertedUtc + }; + + return details; +} diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration/functions.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration/functions.bal new file mode 100644 index 00000000000..5ddef56a715 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration/functions.bal @@ -0,0 +1,215 @@ +import ballerina/http; +import ballerina/lang.regexp; +import ballerina/log; +import ballerina/time; + +// Check if lead passes the filters +function shouldProcessLead(Lead lead) returns boolean { + // Filter by lead source if configured + if notificationConfig.filterLeadSources.length() > 0 { + string? leadSource = lead.LeadSource; + if leadSource is string { + boolean sourceMatches = false; + foreach string allowedSource in notificationConfig.filterLeadSources { + if leadSource == allowedSource { + sourceMatches = true; + break; + } + } + if !sourceMatches { + return false; + } + } else { + return false; + } + } + + // Filter by owner ID if configured + if notificationConfig.filterOwnerIds.length() > 0 { + string? ownerId = lead.OwnerId; + if ownerId is string { + boolean ownerMatches = false; + foreach string allowedOwnerId in notificationConfig.filterOwnerIds { + if ownerId == allowedOwnerId { + ownerMatches = true; + break; + } + } + if !ownerMatches { + return false; + } + } else { + return false; + } + } + + return true; +} + +// Normalize Salesforce date string to RFC 3339 (e.g. +0000 -> +00:00) +function normalizeSfDate(string date) returns string { + // If ends with ±HHMM (no colon), insert colon before last 2 chars + if date.length() >= 5 { + string suffix = date.substring(date.length() - 5); + if re `[+-]\d{4}`.isFullMatch(suffix) { + return date.substring(0, date.length() - 2) + ":" + date.substring(date.length() - 2); + } + } + return date; +} + +// Normalize a date string to full RFC 3339 (handles date-only "YYYY-MM-DD") +function toRfc3339(string date) returns string { + string normalized = normalizeSfDate(date); + // If date-only (no 'T'), append midnight UTC + if !normalized.includes("T") { + return normalized + "T00:00:00.000Z"; + } + return normalized; +} + +// Calculate lead lifecycle duration in days +function calculateLifecycleDuration(string createdDate, string convertedDate) returns decimal|error { + time:Utc createdUtc = check time:utcFromString(toRfc3339(createdDate)); + time:Utc convertedUtc = check time:utcFromString(toRfc3339(convertedDate)); + + time:Seconds durationSeconds = time:utcDiffSeconds(convertedUtc, createdUtc); + decimal durationDays = durationSeconds / 86400.0d; + + return durationDays; +} + +// Get Salesforce instance URL for building links +function getSalesforceInstanceUrl() returns string { + string baseUrl = salesforceConfig.baseUrl; + regexp:RegExp pattern = re `^(https://[^/]+)`; + regexp:Groups? groups = pattern.findGroups(baseUrl); + + if groups is regexp:Groups { + string instanceUrl = groups[0].substring(); + return instanceUrl; + } + + return baseUrl; +} + +// Format the Slack message +function formatSlackMessage(LeadConversionDetails details, string? slackUserId) returns string { + string message = notificationConfig.messageTemplate; + + // Replace template variables + regexp:RegExp leadNamePattern = re `\{\{lead\.name\}\}`; + message = leadNamePattern.replaceAll(message, details.leadName); + + regexp:RegExp companyPattern = re `\{\{lead\.company\}\}`; + message = companyPattern.replaceAll(message, details.company); + + // Tag owner in Slack if user ID is available + string ownerMention = slackUserId is string ? "<@" + slackUserId + ">" : details.ownerName; + regexp:RegExp ownerPattern = re `\{\{lead\.owner\}\}`; + message = ownerPattern.replaceAll(message, ownerMention); + + // Add conversion details if enabled + if notificationConfig.includeConversionDetails { + message = message + "\n\n*Conversion Details:*"; + message = message + "\n• *Account:* " + details.accountName; + message = message + "\n• *Contact:* " + details.contactName; + message = message + "\n• *Opportunity:* " + details.opportunityName; + message = message + "\n• *Opportunity Link:* " + details.opportunityLink; + + // Format lifecycle duration + int durationDays = details.lifecycleDurationDays < 1 ? 1 : details.lifecycleDurationDays; + message = message + "\n• *Lead Lifecycle Duration:* " + durationDays.toString() + " days"; + } + + return message; +} + +// Determine the target Slack channel based on team mapping +function determineSlackChannel(string? ownerId) returns string { + if ownerId is () { + return slackConfig.defaultChannel; + } + + // Get owner details to find team + string ownerQuery = string `SELECT Id, Name, UserRole.Name FROM User WHERE Id = '${ownerId}'`; + stream|error ownerStream = salesforceClient->query(ownerQuery); + if ownerStream is error { + log:printError("Failed to query Salesforce for owner details", ownerStream); + return slackConfig.defaultChannel; + } + + record {|record {} value;|}|error? ownerResult = ownerStream.next(); + error? closeErr = ownerStream.close(); + if closeErr is error { + log:printWarn("Failed to close Salesforce owner stream", closeErr); + } + + if ownerResult is error { + log:printError("Failed to read owner record from Salesforce stream", ownerResult); + return slackConfig.defaultChannel; + } + if ownerResult is () { + return slackConfig.defaultChannel; + } + + record {} ownerRecord = ownerResult.value; + + // Try to match team from role name + foreach TeamChannelMapping mapping in notificationConfig.teamChannelMappings { + string teamName = mapping.teamName; + + // Check if role name contains team name + if ownerRecord.hasKey("UserRole") { + anydata userRoleData = ownerRecord.get("UserRole"); + if userRoleData is record {} { + record {} userRole = userRoleData; + if userRole.hasKey("Name") { + string|error roleName = userRole.get("Name").ensureType(); + if roleName is error { + continue; + } + if roleName.toLowerAscii().includes(teamName.toLowerAscii()) { + return mapping.channel; + } + } + } + } + } + + return slackConfig.defaultChannel; +} + +// Get Slack user ID from email +function getSlackUserIdFromEmail(string? email) returns string?|error { + if email is () { + return (); + } + + // Use raw HTTP client — slack connector incorrectly types the user field as an array + http:Response|error httpResponse = slackHttpClient->get("/api/users.lookupByEmail?email=" + email); + if httpResponse is error { + log:printWarn("Failed to lookup Slack user by email", 'error = httpResponse, email = email); + return (); + } + + json|error payload = httpResponse.getJsonPayload(); + if payload is error { + log:printWarn("Failed to parse Slack lookupByEmail response", 'error = payload, email = email); + return (); + } + + boolean|error ok = payload.ok.ensureType(); + if ok is error || !ok { + log:printWarn("Slack lookupByEmail returned not ok", email = email); + return (); + } + + string|error userId = payload.user.id.ensureType(); + if userId is error { + log:printWarn("Slack user ID not found in response", email = email); + return (); + } + + return userId; +} diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration/main.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration/main.bal new file mode 100644 index 00000000000..a7e7ae9f1a4 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration/main.bal @@ -0,0 +1,160 @@ +import ballerina/log; +import ballerinax/salesforce; + +// Deduplication cache to prevent processing the same lead conversion event twice +isolated map processedLeadIds = {}; + +// Salesforce listener configuration +listener salesforce:Listener salesforceListener = new ({ + baseUrl: salesforceConfig.baseUrl, + auth: { + clientId: salesforceConfig.clientId, + clientSecret: salesforceConfig.clientSecret, + refreshToken: salesforceConfig.refreshToken, + refreshUrl: salesforceConfig.refreshUrl + } +}); + +// Service to listen to Salesforce Lead change events +service "/data/ChangeEvents" on salesforceListener { + + remote function onCreate(salesforce:EventData eventData) returns error? { + log:printInfo("onCreate event received"); + } + + remote function onDelete(salesforce:EventData eventData) returns error? { + log:printInfo("onDelete event received"); + } + + remote function onRestore(salesforce:EventData eventData) returns error? { + log:printInfo("onRestore event received"); + } + + remote function onUpdate(salesforce:EventData eventData) returns error? { + log:printInfo("onUpdate event received"); + error? result = processLeadConversion(eventData); + if result is error { + log:printError("Lead conversion processing failed", 'error = result); + } + } +} + +function processLeadConversion(salesforce:EventData eventData) returns error? { + // Extract the changed data + map changedData = >eventData.changedData; + + // Check if this is a lead conversion event + if !changedData.hasKey("IsConverted") { + return; + } + + json isConvertedValue = changedData.get("IsConverted"); + boolean isConverted = isConvertedValue.toString() == "true"; + + if !isConverted { + return; + } + + // Get the lead ID from metadata + salesforce:ChangeEventMetadata metadata = check (eventData.metadata ?: error("Missing metadata")).ensureType(); + string leadId = metadata.recordId ?: ""; + + if leadId == "" { + log:printError("Missing recordId in Salesforce change event metadata"); + return; + } + + // Deduplicate: skip if this lead was already processed (guards against duplicate events) + lock { + if processedLeadIds.hasKey(leadId) { + log:printInfo("Duplicate lead conversion event, skipping", leadId = leadId); + return; + } + processedLeadIds[leadId] = true; + } + + // Query full lead details + string leadQuery = string `SELECT Id, Name, Company, LeadSource, OwnerId, ConvertedAccountId, ConvertedContactId, ConvertedOpportunityId, IsConverted, CreatedDate, ConvertedDate FROM Lead WHERE Id = '${leadId}'`; + + stream leadStream = check salesforceClient->query(leadQuery); + record {|Lead value;|}? leadResult = check leadStream.next(); + check leadStream.close(); + + if leadResult is () { + log:printError("Lead not found", leadId = leadId); + return; + } + + Lead lead = leadResult.value; + + // Apply filters + if !shouldProcessLead(lead) { + log:printInfo("Lead filtered out", leadId = leadId); + return; + } + + // Check if lead has all conversion data + string? accountId = lead.ConvertedAccountId; + string? contactId = lead.ConvertedContactId; + string? opportunityId = lead.ConvertedOpportunityId; + string? ownerId = lead.OwnerId; + + if accountId is () || contactId is () || opportunityId is () || ownerId is () { + log:printError("Lead missing conversion data", leadId = leadId); + return; + } + + // Fetch owner details + string ownerQuery = string `SELECT Id, Name, Email FROM User WHERE Id = '${ownerId}'`; + stream ownerStream = check salesforceClient->query(ownerQuery); + record {|LeadOwner value;|}? ownerResult = check ownerStream.next(); + check ownerStream.close(); + + if ownerResult is () { + log:printError("Owner not found", ownerId = ownerId); + return; + } + + LeadOwner owner = ownerResult.value; + + // Fetch converted account details + ConvertedAccount account = check salesforceClient->getById("Account", accountId); + + // Fetch converted contact details + ConvertedContact contact = check salesforceClient->getById("Contact", contactId); + + // Fetch converted opportunity details + ConvertedOpportunity opportunity = check salesforceClient->getById("Opportunity", opportunityId); + + // Build conversion details + LeadConversionDetails conversionDetails = check buildLeadConversionDetails( + lead, + owner, + account, + contact, + opportunity + ); + + // Get Slack user ID for tagging (if available) + string? slackUserId = check getSlackUserIdFromEmail(owner.Email); + + // Determine target Slack channel + string targetChannel = determineSlackChannel(ownerId); + + log:printInfo(targetChannel); + + // Format the message + string slackMessage = formatSlackMessage(conversionDetails, slackUserId); + + // Send Slack notification + _ = check slackClient->/chat\.postMessage.post({ + channel: targetChannel, + text: slackMessage, + mrkdwn: true + }); + + log:printInfo("Slack notification sent successfully", + leadId = leadId, + channel = targetChannel + ); +} diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration/types.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration/types.bal new file mode 100644 index 00000000000..b91d4751426 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration/types.bal @@ -0,0 +1,65 @@ +import ballerina/time; + +// Configuration types +type TeamChannelMapping record {| + string teamName; + string channel; +|}; + +// Salesforce Lead related types +type Lead record {| + string Id; + string Name; + string Company; + string? LeadSource; + string? OwnerId; + string? ConvertedAccountId; + string? ConvertedContactId; + string? ConvertedOpportunityId; + boolean IsConverted; + string CreatedDate; + string? ConvertedDate; + anydata...; +|}; + +type LeadOwner record {| + string Id; + string Name; + string? Email; + anydata...; +|}; + +type ConvertedAccount record {| + string Id; + string Name; + anydata...; +|}; + +type ConvertedContact record {| + string Id; + string Name; + anydata...; +|}; + +type ConvertedOpportunity record {| + string Id; + string Name; + anydata...; +|}; + +// Message formatting types +type LeadConversionDetails record {| + string leadId; + string leadName; + string company; + string ownerName; + string? ownerId; + string? leadSource; + string accountName; + string contactName; + string opportunityName; + string opportunityId; + string opportunityLink; + decimal lifecycleDurationDays; + time:Utc convertedTime; +|}; diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/Ballerina.toml b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/Ballerina.toml new file mode 100644 index 00000000000..21a4ea167e8 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/Ballerina.toml @@ -0,0 +1,6 @@ +[package] +org = "wso2" +name = "salesforce_slack_integration_errors" +version = "0.1.0" +title = "salesforce_slack_integration_errors" +distribution = "2201.13.2" diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/agents.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/agents.bal new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/config.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/config.bal new file mode 100644 index 00000000000..a0ec5f25431 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/config.bal @@ -0,0 +1,20 @@ +configurable record { + string baseUrl; + string clientId; + string clientSecret; + string refreshToken; + string refreshUrl; +} salesforceConfig = ?; + +configurable record { + string token; + string defaultChannel; +} slackConfig = ?; + +configurable record { + string messageTemplate = "🎉 Lead Converted!\n*Lead:* {{lead.name}}\n*Company:* {{lead.company}}\n*Owner:* {{lead.owner}}"; + string[] filterLeadSources = []; + string[] filterOwnerIds = []; + TeamChannelMapping[] teamChannelMappings = []; + boolean includeConversionDetails = true; +} notificationConfig = ?; diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/connections.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/connections.bal new file mode 100644 index 00000000000..c942bc08b50 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/connections.bal @@ -0,0 +1,26 @@ +import ballerina/http; +import ballerinax/salesforce; +import ballerinax/slack; + +// Salesforce client initialization +final salesforce:Client salesforceClient = check new ({ + baseUrl: salesforceConfig.baseUrl, + auth: { + clientId: salesforceConfig.clientId, + clientSecret: salesforceConfig.clientSecret, + refreshToken: salesforceConfig.refreshToken, + refreshUrl: salesforceConfig.refreshUrl + } +}); + +// Slack client initialization +final slack:Client slackClient = check new ({ + auth: { + token: slackConfig.token + } +}); + +// Raw Slack HTTP client for APIs with broken connector type bindings +final http:Client slackHttpClient = check new ("https://slack.com", { + auth: {token: slackConfig.token} +}); diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/data_mappings.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/data_mappings.bal new file mode 100644 index 00000000000..6ba67d002c7 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/data_mappings.bal @@ -0,0 +1,37 @@ +import ballerina/time; + +// Build lead conversion details from Salesforce data +function buildLeadConversionDetails( + Lead lead, + LeadOwner owner, + ConvertedAccount account, + ConvertedContact contact, + ConvertedOpportunity opportunity +) returns LeadConversionDetails|error { + + string convertedDate = lead.ConvertedDate ?: lead.CreatedDate; + decimal lifecycleDuration = check calculateLifecycleDuration(lead.CreatedDate, convertedDate); + + time:Utc convertedUtc = check time:utcFromString(toRfc3339(convertedDate)); + + string instanceUrl = getSalesforceInstanceUrl(); + string opportunityLink = instanceUrl + "/" + opportunity.Id; + + LeadConversionDetails details = { + leadId: lead.Id, + leadName: lead.Name, + company: lead.Company, + ownerName: owner.Name, + ownerId: owner.Id, + leadSource: lead.LeadSource, + accountName: account.Name, + contactName: contact.Name, + opportunityName: opportunity.Name, + opportunityId: opportunity.Id, + opportunityLink: opportunityLink, + lifecycleDurationDays: lifecycleDuration, + convertedTime: convertedUtc + }; + + return details; +} diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/functions.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/functions.bal new file mode 100644 index 00000000000..5ddef56a715 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/functions.bal @@ -0,0 +1,215 @@ +import ballerina/http; +import ballerina/lang.regexp; +import ballerina/log; +import ballerina/time; + +// Check if lead passes the filters +function shouldProcessLead(Lead lead) returns boolean { + // Filter by lead source if configured + if notificationConfig.filterLeadSources.length() > 0 { + string? leadSource = lead.LeadSource; + if leadSource is string { + boolean sourceMatches = false; + foreach string allowedSource in notificationConfig.filterLeadSources { + if leadSource == allowedSource { + sourceMatches = true; + break; + } + } + if !sourceMatches { + return false; + } + } else { + return false; + } + } + + // Filter by owner ID if configured + if notificationConfig.filterOwnerIds.length() > 0 { + string? ownerId = lead.OwnerId; + if ownerId is string { + boolean ownerMatches = false; + foreach string allowedOwnerId in notificationConfig.filterOwnerIds { + if ownerId == allowedOwnerId { + ownerMatches = true; + break; + } + } + if !ownerMatches { + return false; + } + } else { + return false; + } + } + + return true; +} + +// Normalize Salesforce date string to RFC 3339 (e.g. +0000 -> +00:00) +function normalizeSfDate(string date) returns string { + // If ends with ±HHMM (no colon), insert colon before last 2 chars + if date.length() >= 5 { + string suffix = date.substring(date.length() - 5); + if re `[+-]\d{4}`.isFullMatch(suffix) { + return date.substring(0, date.length() - 2) + ":" + date.substring(date.length() - 2); + } + } + return date; +} + +// Normalize a date string to full RFC 3339 (handles date-only "YYYY-MM-DD") +function toRfc3339(string date) returns string { + string normalized = normalizeSfDate(date); + // If date-only (no 'T'), append midnight UTC + if !normalized.includes("T") { + return normalized + "T00:00:00.000Z"; + } + return normalized; +} + +// Calculate lead lifecycle duration in days +function calculateLifecycleDuration(string createdDate, string convertedDate) returns decimal|error { + time:Utc createdUtc = check time:utcFromString(toRfc3339(createdDate)); + time:Utc convertedUtc = check time:utcFromString(toRfc3339(convertedDate)); + + time:Seconds durationSeconds = time:utcDiffSeconds(convertedUtc, createdUtc); + decimal durationDays = durationSeconds / 86400.0d; + + return durationDays; +} + +// Get Salesforce instance URL for building links +function getSalesforceInstanceUrl() returns string { + string baseUrl = salesforceConfig.baseUrl; + regexp:RegExp pattern = re `^(https://[^/]+)`; + regexp:Groups? groups = pattern.findGroups(baseUrl); + + if groups is regexp:Groups { + string instanceUrl = groups[0].substring(); + return instanceUrl; + } + + return baseUrl; +} + +// Format the Slack message +function formatSlackMessage(LeadConversionDetails details, string? slackUserId) returns string { + string message = notificationConfig.messageTemplate; + + // Replace template variables + regexp:RegExp leadNamePattern = re `\{\{lead\.name\}\}`; + message = leadNamePattern.replaceAll(message, details.leadName); + + regexp:RegExp companyPattern = re `\{\{lead\.company\}\}`; + message = companyPattern.replaceAll(message, details.company); + + // Tag owner in Slack if user ID is available + string ownerMention = slackUserId is string ? "<@" + slackUserId + ">" : details.ownerName; + regexp:RegExp ownerPattern = re `\{\{lead\.owner\}\}`; + message = ownerPattern.replaceAll(message, ownerMention); + + // Add conversion details if enabled + if notificationConfig.includeConversionDetails { + message = message + "\n\n*Conversion Details:*"; + message = message + "\n• *Account:* " + details.accountName; + message = message + "\n• *Contact:* " + details.contactName; + message = message + "\n• *Opportunity:* " + details.opportunityName; + message = message + "\n• *Opportunity Link:* " + details.opportunityLink; + + // Format lifecycle duration + int durationDays = details.lifecycleDurationDays < 1 ? 1 : details.lifecycleDurationDays; + message = message + "\n• *Lead Lifecycle Duration:* " + durationDays.toString() + " days"; + } + + return message; +} + +// Determine the target Slack channel based on team mapping +function determineSlackChannel(string? ownerId) returns string { + if ownerId is () { + return slackConfig.defaultChannel; + } + + // Get owner details to find team + string ownerQuery = string `SELECT Id, Name, UserRole.Name FROM User WHERE Id = '${ownerId}'`; + stream|error ownerStream = salesforceClient->query(ownerQuery); + if ownerStream is error { + log:printError("Failed to query Salesforce for owner details", ownerStream); + return slackConfig.defaultChannel; + } + + record {|record {} value;|}|error? ownerResult = ownerStream.next(); + error? closeErr = ownerStream.close(); + if closeErr is error { + log:printWarn("Failed to close Salesforce owner stream", closeErr); + } + + if ownerResult is error { + log:printError("Failed to read owner record from Salesforce stream", ownerResult); + return slackConfig.defaultChannel; + } + if ownerResult is () { + return slackConfig.defaultChannel; + } + + record {} ownerRecord = ownerResult.value; + + // Try to match team from role name + foreach TeamChannelMapping mapping in notificationConfig.teamChannelMappings { + string teamName = mapping.teamName; + + // Check if role name contains team name + if ownerRecord.hasKey("UserRole") { + anydata userRoleData = ownerRecord.get("UserRole"); + if userRoleData is record {} { + record {} userRole = userRoleData; + if userRole.hasKey("Name") { + string|error roleName = userRole.get("Name").ensureType(); + if roleName is error { + continue; + } + if roleName.toLowerAscii().includes(teamName.toLowerAscii()) { + return mapping.channel; + } + } + } + } + } + + return slackConfig.defaultChannel; +} + +// Get Slack user ID from email +function getSlackUserIdFromEmail(string? email) returns string?|error { + if email is () { + return (); + } + + // Use raw HTTP client — slack connector incorrectly types the user field as an array + http:Response|error httpResponse = slackHttpClient->get("/api/users.lookupByEmail?email=" + email); + if httpResponse is error { + log:printWarn("Failed to lookup Slack user by email", 'error = httpResponse, email = email); + return (); + } + + json|error payload = httpResponse.getJsonPayload(); + if payload is error { + log:printWarn("Failed to parse Slack lookupByEmail response", 'error = payload, email = email); + return (); + } + + boolean|error ok = payload.ok.ensureType(); + if ok is error || !ok { + log:printWarn("Slack lookupByEmail returned not ok", email = email); + return (); + } + + string|error userId = payload.user.id.ensureType(); + if userId is error { + log:printWarn("Slack user ID not found in response", email = email); + return (); + } + + return userId; +} diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/main.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/main.bal new file mode 100644 index 00000000000..a7e7ae9f1a4 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/main.bal @@ -0,0 +1,160 @@ +import ballerina/log; +import ballerinax/salesforce; + +// Deduplication cache to prevent processing the same lead conversion event twice +isolated map processedLeadIds = {}; + +// Salesforce listener configuration +listener salesforce:Listener salesforceListener = new ({ + baseUrl: salesforceConfig.baseUrl, + auth: { + clientId: salesforceConfig.clientId, + clientSecret: salesforceConfig.clientSecret, + refreshToken: salesforceConfig.refreshToken, + refreshUrl: salesforceConfig.refreshUrl + } +}); + +// Service to listen to Salesforce Lead change events +service "/data/ChangeEvents" on salesforceListener { + + remote function onCreate(salesforce:EventData eventData) returns error? { + log:printInfo("onCreate event received"); + } + + remote function onDelete(salesforce:EventData eventData) returns error? { + log:printInfo("onDelete event received"); + } + + remote function onRestore(salesforce:EventData eventData) returns error? { + log:printInfo("onRestore event received"); + } + + remote function onUpdate(salesforce:EventData eventData) returns error? { + log:printInfo("onUpdate event received"); + error? result = processLeadConversion(eventData); + if result is error { + log:printError("Lead conversion processing failed", 'error = result); + } + } +} + +function processLeadConversion(salesforce:EventData eventData) returns error? { + // Extract the changed data + map changedData = >eventData.changedData; + + // Check if this is a lead conversion event + if !changedData.hasKey("IsConverted") { + return; + } + + json isConvertedValue = changedData.get("IsConverted"); + boolean isConverted = isConvertedValue.toString() == "true"; + + if !isConverted { + return; + } + + // Get the lead ID from metadata + salesforce:ChangeEventMetadata metadata = check (eventData.metadata ?: error("Missing metadata")).ensureType(); + string leadId = metadata.recordId ?: ""; + + if leadId == "" { + log:printError("Missing recordId in Salesforce change event metadata"); + return; + } + + // Deduplicate: skip if this lead was already processed (guards against duplicate events) + lock { + if processedLeadIds.hasKey(leadId) { + log:printInfo("Duplicate lead conversion event, skipping", leadId = leadId); + return; + } + processedLeadIds[leadId] = true; + } + + // Query full lead details + string leadQuery = string `SELECT Id, Name, Company, LeadSource, OwnerId, ConvertedAccountId, ConvertedContactId, ConvertedOpportunityId, IsConverted, CreatedDate, ConvertedDate FROM Lead WHERE Id = '${leadId}'`; + + stream leadStream = check salesforceClient->query(leadQuery); + record {|Lead value;|}? leadResult = check leadStream.next(); + check leadStream.close(); + + if leadResult is () { + log:printError("Lead not found", leadId = leadId); + return; + } + + Lead lead = leadResult.value; + + // Apply filters + if !shouldProcessLead(lead) { + log:printInfo("Lead filtered out", leadId = leadId); + return; + } + + // Check if lead has all conversion data + string? accountId = lead.ConvertedAccountId; + string? contactId = lead.ConvertedContactId; + string? opportunityId = lead.ConvertedOpportunityId; + string? ownerId = lead.OwnerId; + + if accountId is () || contactId is () || opportunityId is () || ownerId is () { + log:printError("Lead missing conversion data", leadId = leadId); + return; + } + + // Fetch owner details + string ownerQuery = string `SELECT Id, Name, Email FROM User WHERE Id = '${ownerId}'`; + stream ownerStream = check salesforceClient->query(ownerQuery); + record {|LeadOwner value;|}? ownerResult = check ownerStream.next(); + check ownerStream.close(); + + if ownerResult is () { + log:printError("Owner not found", ownerId = ownerId); + return; + } + + LeadOwner owner = ownerResult.value; + + // Fetch converted account details + ConvertedAccount account = check salesforceClient->getById("Account", accountId); + + // Fetch converted contact details + ConvertedContact contact = check salesforceClient->getById("Contact", contactId); + + // Fetch converted opportunity details + ConvertedOpportunity opportunity = check salesforceClient->getById("Opportunity", opportunityId); + + // Build conversion details + LeadConversionDetails conversionDetails = check buildLeadConversionDetails( + lead, + owner, + account, + contact, + opportunity + ); + + // Get Slack user ID for tagging (if available) + string? slackUserId = check getSlackUserIdFromEmail(owner.Email); + + // Determine target Slack channel + string targetChannel = determineSlackChannel(ownerId); + + log:printInfo(targetChannel); + + // Format the message + string slackMessage = formatSlackMessage(conversionDetails, slackUserId); + + // Send Slack notification + _ = check slackClient->/chat\.postMessage.post({ + channel: targetChannel, + text: slackMessage, + mrkdwn: true + }); + + log:printInfo("Slack notification sent successfully", + leadId = leadId, + channel = targetChannel + ); +} diff --git a/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/types.bal b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/types.bal new file mode 100644 index 00000000000..b91d4751426 --- /dev/null +++ b/packages/ballerina-extension/test/data/salesforce_slack_integration_errors/types.bal @@ -0,0 +1,65 @@ +import ballerina/time; + +// Configuration types +type TeamChannelMapping record {| + string teamName; + string channel; +|}; + +// Salesforce Lead related types +type Lead record {| + string Id; + string Name; + string Company; + string? LeadSource; + string? OwnerId; + string? ConvertedAccountId; + string? ConvertedContactId; + string? ConvertedOpportunityId; + boolean IsConverted; + string CreatedDate; + string? ConvertedDate; + anydata...; +|}; + +type LeadOwner record {| + string Id; + string Name; + string? Email; + anydata...; +|}; + +type ConvertedAccount record {| + string Id; + string Name; + anydata...; +|}; + +type ConvertedContact record {| + string Id; + string Name; + anydata...; +|}; + +type ConvertedOpportunity record {| + string Id; + string Name; + anydata...; +|}; + +// Message formatting types +type LeadConversionDetails record {| + string leadId; + string leadName; + string company; + string ownerName; + string? ownerId; + string? leadSource; + string accountName; + string contactName; + string opportunityName; + string opportunityId; + string opportunityLink; + decimal lifecycleDurationDays; + time:Utc convertedTime; +|}; diff --git a/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/Ballerina.toml b/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/Ballerina.toml new file mode 100644 index 00000000000..dd5f7c89627 --- /dev/null +++ b/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/Ballerina.toml @@ -0,0 +1,6 @@ +[package] +org = "wso2" +name = "shopify_stripe_integration" +version = "0.2.0" +title = "shopify_stripe_integration" +distribution= "2201.13.1" diff --git a/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/agents.bal b/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/agents.bal new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/automation.bal b/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/automation.bal new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/config.bal b/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/config.bal new file mode 100644 index 00000000000..2387af978f1 --- /dev/null +++ b/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/config.bal @@ -0,0 +1,7 @@ +configurable record { + string apiSecretKey; +} shopifyConfig = ?; + +configurable record { + string secretKey; +} stripeConfig = ?; diff --git a/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/connections.bal b/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/connections.bal new file mode 100644 index 00000000000..4411d3622b6 --- /dev/null +++ b/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/connections.bal @@ -0,0 +1,16 @@ +import ballerinax/trigger.shopify; +import ballerinax/stripe; + +shopifyListenerConfig listenerConfig = { + apiSecretKey: shopifyConfig.apiSecretKey +}; + +listener shopify:Listener shopifyListener = new(listenerConfig, 9090); + +stripe:ConnectionConfig configuration = { + auth: { + token: stripeConfig.secretKey + } +}; + +stripe:Client stripe = check new (configuration); diff --git a/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/data_mappings.bal b/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/data_mappings.bal new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/functions.bal b/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/functions.bal new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/main.bal b/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/main.bal new file mode 100644 index 00000000000..855d40a739d --- /dev/null +++ b/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/main.bal @@ -0,0 +1,34 @@ +import ballerinax/trigger.shopify; +import ballerinax/stripe; +import ballerina/log; + +service shopify:CustomersService on shopifyListener { + remote function onCustomersCreate(shopify:CustomerEvent event) returns error? { + if ((event?.first_name == () && event?.last_name == ()) || event?.email == ()) { + log:printInfo("Skipping customer creation in Stripe for Shopify customer with missing details: " + (event?.id.toString())); + return; + } + stripe:customers_body customer = { + name: string:'join(" ", event?.first_name ?: "", event?.last_name ?: "").trim(), + email: event?.email + }; + _ = check stripe->/customers.post(customer); + log:printInfo("Customer created in Stripe for Shopify customer: " + (event?.email ?: "")); + } + + remotee function onCustomersDisable(shopify:CustomerEvent event) returns error? { + return; + } + + remote function onCustomersEnable(shopify:CustomerEvent event) returns error? { + return; + } + + remote function onCustomersMarketingConsentUpdate(shopify:CustomerEvent event) returns error? { + return; + } + + remote function onCustomersUpdate(shopify:CustomerEvent event) returns error? { + return; + } +} diff --git a/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/types.bal b/packages/ballerina-extension/test/data/shopify_stripe_integration_errors/types.bal new file mode 100644 index 00000000000..e69de29bb2d From 3e84aee7386a805f3dcbe6df988193a640ad108d Mon Sep 17 00:00:00 2001 From: Yasith Rashan Date: Sun, 31 May 2026 03:38:34 +0530 Subject: [PATCH 3/3] Add ripgrep-based grep and glob tools --- common/config/rush/pnpm-lock.yaml | 14 ++ packages/ballerina-extension/package.json | 1 + .../src/features/ai/agent/prompts.ts | 4 +- .../src/features/ai/agent/tool-registry.ts | 8 + .../src/features/ai/agent/tools/glob.ts | 161 +++++++++++++++ .../src/features/ai/agent/tools/grep.ts | 191 ++++++++++++++++++ .../features/ai/agent/tools/utils/rg-utils.ts | 97 +++++++++ 7 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 packages/ballerina-extension/src/features/ai/agent/tools/glob.ts create mode 100644 packages/ballerina-extension/src/features/ai/agent/tools/grep.ts create mode 100644 packages/ballerina-extension/src/features/ai/agent/tools/utils/rg-utils.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 6543932e4ac..6cc40f4d4ee 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@types/lodash': specifier: 4.14.200 version: 4.14.200 + '@vscode/ripgrep': + specifier: 1.15.9 + version: 1.15.9 '@vscode/test-electron': specifier: 2.5.2 version: 2.5.2 @@ -7566,6 +7569,9 @@ packages: '@vscode/codicons@0.0.44': resolution: {integrity: sha512-F7qPRumUK3EHjNdopfICLGRf3iNPoZQt+McTHAn4AlOWPB3W2kL4H0S7uqEqbyZ6rCxaeDjpAn3MCUnwTu/VJQ==} + '@vscode/ripgrep@1.15.9': + resolution: {integrity: sha512-4q2PXRvUvr3bF+LsfrifmUZgSPmCNcUZo6SbEAZgArIChchkezaxLoIeQMJe/z3CCKStvaVKpBXLxN3Z8lQjFQ==} + '@vscode/test-electron@2.5.2': resolution: {integrity: sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==} engines: {node: '>=16'} @@ -28291,6 +28297,14 @@ snapshots: '@vscode/codicons@0.0.44': {} + '@vscode/ripgrep@1.15.9': + dependencies: + https-proxy-agent: 7.0.6 + proxy-from-env: 1.1.0 + yauzl: 2.10.0 + transitivePeerDependencies: + - supports-color + '@vscode/test-electron@2.5.2': dependencies: http-proxy-agent: 7.0.2 diff --git a/packages/ballerina-extension/package.json b/packages/ballerina-extension/package.json index 894605f8739..68ff0a1e33a 100644 --- a/packages/ballerina-extension/package.json +++ b/packages/ballerina-extension/package.json @@ -1436,6 +1436,7 @@ "copyJSLibs": "copyfiles -f ../ballerina-visualizer/build/*.js resources/jslibs && copyfiles -f ../ballerina-visualizer/build/images/* resources/jslibs/images && copyfiles -f ../trace-visualizer/build/*.js resources/jslibs && copyfiles -f ../graphql/build/*.js resources/jslibs" }, "dependencies": { + "@vscode/ripgrep": "1.15.9", "@ai-sdk/amazon-bedrock": "4.0.83", "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/google-vertex": "4.0.94", diff --git a/packages/ballerina-extension/src/features/ai/agent/prompts.ts b/packages/ballerina-extension/src/features/ai/agent/prompts.ts index 95da1255c5e..d881be19a59 100644 --- a/packages/ballerina-extension/src/features/ai/agent/prompts.ts +++ b/packages/ballerina-extension/src/features/ai/agent/prompts.ts @@ -22,6 +22,8 @@ import { FILE_BATCH_EDIT_TOOL_NAME, FILE_READ_TOOL_NAME, FILE_SINGLE_EDIT_TOOL_N import { CONNECTOR_GENERATOR_TOOL } from "./tools/connector-generator"; import { CONFIG_COLLECTOR_TOOL } from "./tools/config-collector"; import { CLARIFY_TOOL } from "./tools/clarify"; +import { GREP_TOOL_NAME } from "./tools/grep"; +import { GLOB_TOOL_NAME } from "./tools/glob"; import { TEST_RUNNER_TOOL_NAME } from "./tools/test-runner"; import { getLanglibInstructions } from "../utils/libs/langlibs"; import { formatCodebaseStructure, formatCodeContext } from "./utils"; @@ -181,7 +183,7 @@ ${getLanglibInstructions()} - If you receive complete structure of the codebase, it contains the complete source of all .bal files (test and resource files excluded) provided directly in your context. ## Context Retrieval -- Explore the codebase with ${FILE_READ_TOOL_NAME}, and keep exploring until you have all the context required to answer confidently. +- Explore the codebase with ${GREP_TOOL_NAME}, ${FILE_READ_TOOL_NAME}, and ${GLOB_TOOL_NAME}, and keep exploring until you have all the context required to answer confidently. ### Rules for exploration - **DO NOT** guess the implementation based on signatures from Codebase High Level Overview or excerpts you retrieved. Always read the actual source code before using any information about a component in the codebase. This is critical to avoid hallucinations and wrong assumptions. diff --git a/packages/ballerina-extension/src/features/ai/agent/tool-registry.ts b/packages/ballerina-extension/src/features/ai/agent/tool-registry.ts index 3db575dca87..79e4894eef3 100644 --- a/packages/ballerina-extension/src/features/ai/agent/tool-registry.ts +++ b/packages/ballerina-extension/src/features/ai/agent/tool-registry.ts @@ -21,6 +21,8 @@ import { ExecutionContext, ProjectSource } from '@wso2/ballerina-core'; import { CopilotEventHandler } from '../utils/events'; import { createTaskWriteTool, TASK_WRITE_TOOL_NAME } from './tools/task-writer'; import { createDiagnosticsTool, DIAGNOSTICS_TOOL_NAME } from './tools/diagnostics'; +import { createGrepTool, createGrepExecute, GREP_TOOL_NAME } from './tools/grep'; +import { createGlobTool, createGlobExecute, GLOB_TOOL_NAME } from './tools/glob'; import { createBatchEditTool, createEditExecute, @@ -122,6 +124,12 @@ export function createToolRegistry(opts: ToolRegistryOptions) { ), [FILE_READ_TOOL_NAME]: createReadTool( createReadExecute(eventHandler, tempProjectPath) + ), + [GREP_TOOL_NAME]: createGrepTool( + createGrepExecute(eventHandler, tempProjectPath) + ), + [GLOB_TOOL_NAME]: createGlobTool( + createGlobExecute(eventHandler, tempProjectPath) ), [DIAGNOSTICS_TOOL_NAME]: createDiagnosticsTool(tempProjectPath, eventHandler), [TEST_RUNNER_TOOL_NAME]: createTestRunnerTool(tempProjectPath, eventHandler, modifiedFiles, allModifiedFiles, ctx), diff --git a/packages/ballerina-extension/src/features/ai/agent/tools/glob.ts b/packages/ballerina-extension/src/features/ai/agent/tools/glob.ts new file mode 100644 index 00000000000..50035f87b98 --- /dev/null +++ b/packages/ballerina-extension/src/features/ai/agent/tools/glob.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com/) All Rights Reserved. + +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { tool } from "ai"; +import { z } from "zod"; +import { spawnSync } from "child_process"; +import type { CopilotEventHandler } from "../../utils/events"; +import { getRgExecutable, resolveProjectRoots, validateSearchPath, stripRootPrefix } from "./utils/rg-utils"; + +// ============================================================================ +// Constants +// ============================================================================ + +export const GLOB_TOOL_NAME = "glob"; + +const MAX_RESULTS = 500; + +const enum RgExitCode { + MatchesFound = 0, + NoMatches = 1, + Error = 2, +} + +/** Paths and files always excluded from search */ +const EXCLUDE_GLOB_ARGS = ["--glob", "!.git/**", "--glob", "!target/**"]; + +// ============================================================================ +// Types +// ============================================================================ + +interface GlobInput { + pattern: string; + path?: string; +} + +export interface GlobResult { + success: boolean; + message: string; + pattern?: string; + fileCount?: number; + error?: string; +} + +// ============================================================================ +// Tool Execute Function +// ============================================================================ + +export function createGlobExecute(eventHandler: CopilotEventHandler, tempProjectPath: string) { + const roots = resolveProjectRoots(tempProjectPath); + + function fail(message: string, error: string): GlobResult { + const result: GlobResult = { success: false, message, error }; + eventHandler({ type: "tool_result", toolName: GLOB_TOOL_NAME, toolOutput: result }); + return result; + } + + return async (input: GlobInput): Promise => { + const { pattern, path: searchPath } = input; + + eventHandler({ + type: "tool_call", + toolName: GLOB_TOOL_NAME, + toolInput: { pattern, path: searchPath }, + }); + + console.log(`[GlobTool] Pattern: "${pattern}" in ${searchPath || "."}`); + + if (!pattern || pattern.trim().length === 0) { + return fail("Glob pattern cannot be empty.", "Error: Empty pattern"); + } + + const validation = validateSearchPath(tempProjectPath, searchPath, { requireDirectory: true }); + + if (validation.ok === false) { + return fail(validation.message, validation.error); + } + const { resolvedPath } = validation; + + const args: string[] = ["--files", "--glob", pattern, ...EXCLUDE_GLOB_ARGS]; + args.push("--", resolvedPath); + + const proc = spawnSync(getRgExecutable(), args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }); + + if (proc.status === RgExitCode.Error) { + const errMsg = (proc.stderr || "").trim(); + return fail(`ripgrep error: ${errMsg}`, errMsg); + } + + if (proc.status === RgExitCode.NoMatches || !proc.stdout || proc.stdout.trim().length === 0) { + const result: GlobResult = { + success: true, + message: `No files found matching pattern: "${pattern}"`, + pattern, + fileCount: 0, + }; + eventHandler({ type: "tool_result", toolName: GLOB_TOOL_NAME, toolOutput: result }); + return result; + } + + const { normalizedRawRoot } = roots; + const files = proc.stdout + .split("\n") + .filter((l) => l.length > 0) + .map((l) => stripRootPrefix(l, normalizedRawRoot, tempProjectPath)); + + const truncated = files.length > MAX_RESULTS; + const displayed = truncated ? files.slice(0, MAX_RESULTS) : files; + const truncationNote = truncated ? `\n... (truncated, showing ${MAX_RESULTS} of ${files.length} matches)` : ""; + + const result: GlobResult = { + success: true, + message: `Found ${files.length} file(s) matching "${pattern}":\n${displayed.join("\n")}${truncationNote}`, + pattern, + fileCount: files.length, + }; + + eventHandler({ type: "tool_result", toolName: GLOB_TOOL_NAME, toolOutput: result }); + console.log(`[GlobTool] Found ${files.length} file(s).`); + + return result; + }; +} + +// ============================================================================ +// Tool Definition +// ============================================================================ + +export function createGlobTool(execute: (input: GlobInput) => Promise) { + return tool({ + description: ` +Fast file pattern matching tool +- Support glob patterns +- Returns matching file paths +- Use this tool when you need to find files by name patterns +- When you are doing an open ended search that may require multiple rounds of globbing and grepping +`, + inputSchema: z.object({ + pattern: z.string().describe("The glob pattern to match files against."), + path: z + .string() + .optional() + .describe( + 'The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.', + ), + }), + execute, + }); +} \ No newline at end of file diff --git a/packages/ballerina-extension/src/features/ai/agent/tools/grep.ts b/packages/ballerina-extension/src/features/ai/agent/tools/grep.ts new file mode 100644 index 00000000000..cde7a186147 --- /dev/null +++ b/packages/ballerina-extension/src/features/ai/agent/tools/grep.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com/) All Rights Reserved. + +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { tool } from "ai"; +import { z } from "zod"; +import { spawnSync } from "child_process"; +import type { CopilotEventHandler } from "../../utils/events"; +import { getRgExecutable, resolveProjectRoots, validateSearchPath, stripRootPrefix } from "./utils/rg-utils"; + +// ============================================================================ +// Constants +// ============================================================================ + +export const GREP_TOOL_NAME = "grep"; + +const MAX_LINES = 1000; + +/** File globs to search by default in Ballerina projects */ +const DEFAULT_GLOB_ARGS = [ + "--glob", "*.bal", + "--glob", "*.toml", + "--glob", "*.md", + "--glob", "*.json", + "--glob", "*.yaml", + "--glob", "*.yml", + "--glob", "*.sql", +]; + +/** Paths and files always excluded from search */ +const EXCLUDE_GLOB_ARGS = ["--glob", "!.git/**", "--glob", "!target/**", "--glob", "!Config.toml"]; + +/** Base ripgrep flags applied to every search */ +const BASE_RG_ARGS = [ + "--engine", + "default", + "-C", + "2", // show 2 lines of context before and after each match + "--heading", // print the file name once as a header, not on every line + "--line-number", // prefix each output line with its line number +]; + +const enum RgExitCode { + MatchesFound = 0, + NoMatches = 1, + Error = 2, +} + +// ============================================================================ +// Types +// ============================================================================ + +interface GrepInput { + pattern: string; + path?: string; + case_insensitive?: boolean; +} + +export interface GrepResult { + success: boolean; + message: string; + pattern?: string; + matchCount?: number; + error?: string; +} + +// ============================================================================ +// Tool Execute Function +// ============================================================================ + +export function createGrepExecute(eventHandler: CopilotEventHandler, tempProjectPath: string) { + const roots = resolveProjectRoots(tempProjectPath); + + function fail(message: string, error: string): GrepResult { + const result: GrepResult = { success: false, message, error }; + eventHandler({ type: "tool_result", toolName: GREP_TOOL_NAME, toolOutput: result }); + return result; + } + + return async (input: GrepInput): Promise => { + const { pattern, path: searchPath, case_insensitive = false } = input; + + eventHandler({ + type: "tool_call", + toolName: GREP_TOOL_NAME, + toolInput: { pattern, path: searchPath, case_insensitive }, + }); + + console.log(`[GrepTool] Searching for pattern: "${pattern}" in ${searchPath || "."}`); + + if (!pattern || pattern.trim().length === 0) { + return fail("Search pattern cannot be empty.", "Error: Empty pattern"); + } + + const validation = validateSearchPath(tempProjectPath, searchPath); + + if (validation.ok === false) { + return fail(validation.message, validation.error); + } + const { resolvedPath } = validation; + + // Build ripgrep args + const args: string[] = [...BASE_RG_ARGS]; + + if (case_insensitive) { + args.push("--ignore-case"); + } + + args.push(...DEFAULT_GLOB_ARGS); + + args.push(...EXCLUDE_GLOB_ARGS); + + args.push("--", pattern, resolvedPath); + + const proc = spawnSync(getRgExecutable(), args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }); + + if (proc.status === RgExitCode.Error) { + const errMsg = (proc.stderr || "").trim(); + const result: GrepResult = { success: false, message: `ripgrep error: ${errMsg}`, error: errMsg }; + eventHandler({ type: "tool_result", toolName: GREP_TOOL_NAME, toolOutput: result }); + return result; + } + + if (proc.status === RgExitCode.NoMatches || !proc.stdout || proc.stdout.trim().length === 0) { + const result: GrepResult = { success: true, message: `No matches found for pattern: "${pattern}"`, pattern, matchCount: 0 }; + eventHandler({ type: "tool_result", toolName: GREP_TOOL_NAME, toolOutput: result }); + return result; + } + + let lines = proc.stdout.split("\n").filter((l) => l.length > 0); + + // Make paths relative to project root (always strip from tempProjectPath, not resolvedPath, + // so scoped searches like path:'order_service' still return 'order_service/file.bal') + lines = lines.map((line) => stripRootPrefix(line, roots.normalizedRawRoot, tempProjectPath)); + + const truncated = lines.length > MAX_LINES; + const displayed = truncated ? lines.slice(0, MAX_LINES) : lines; + const truncationNote = truncated ? `\n... (truncated, showing ${MAX_LINES} of ${lines.length} lines)` : ""; + const matchLines = lines.filter((l) => /^\d+[:\-]/.test(l)); + + const result: GrepResult = { + success: true, + message: displayed.join("\n") + truncationNote, + pattern, + matchCount: matchLines.length, + }; + + eventHandler({ type: "tool_result", toolName: GREP_TOOL_NAME, toolOutput: result }); + console.log(`[GrepTool] ripgrep returned ${lines.length} lines.`); + + return result; + }; +} + +// ============================================================================ +// Tool Definition +// ============================================================================ + +export function createGrepTool(execute: (input: GrepInput) => Promise) { + return tool({ + description: ` +A powerful search tool built on ripgrep. +Usage: + - ALWAYS use Grep for search tasks. NEVER invoke \`grep\` as a Bash command. + - Supports full ripgrep regex syntax + - Always returns matching lines with 2 lines of surrounding context and grouped by file + - Only searches these file types by default: .bal, .toml, .md, .json, .yaml, .yml, .sql — searches for other extensions will return no results +`, + inputSchema: z.object({ + pattern: z.string().describe("The regular expression pattern to search for in file contents"), + path: z + .string() + .optional() + .describe("File or directory to search in (rg PATH). Defaults to searching the entire project."), + case_insensitive: z.boolean().optional().describe("Case insensitive search. Defaults to false."), + }), + execute, + }); +} \ No newline at end of file diff --git a/packages/ballerina-extension/src/features/ai/agent/tools/utils/rg-utils.ts b/packages/ballerina-extension/src/features/ai/agent/tools/utils/rg-utils.ts new file mode 100644 index 00000000000..615933f7b1b --- /dev/null +++ b/packages/ballerina-extension/src/features/ai/agent/tools/utils/rg-utils.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com/) All Rights Reserved. + +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import * as fs from "fs"; +import * as path from "path"; +import { spawnSync } from "child_process"; +import { rgPath as builtinRgPath } from "@vscode/ripgrep"; + +// Prefers bundled @vscode/ripgrep; falls back to system `rg` +export function getRgExecutable(): string { + if (fs.existsSync(builtinRgPath)) { + return builtinRgPath; + } + const whichCmd = process.platform === "win32" ? "where" : "which"; + const result = spawnSync(whichCmd, ["rg"], { encoding: "utf-8" }); + if (result.status === 0 && result.stdout?.trim()) { + return result.stdout.trim(); + } + throw new Error("ripgrep binary not found. Install `@vscode/ripgrep` or ensure `rg` is on PATH."); +} + +export interface ProjectRoots { + normalizedRawRoot: string; +} + +export function resolveProjectRoots(tempProjectPath: string): ProjectRoots { + return { + normalizedRawRoot: tempProjectPath.endsWith(path.sep) ? tempProjectPath : tempProjectPath + path.sep, + }; +} + +// resolvedPath is safe to pass to ripgrep +export type PathValidationResult = { ok: true; resolvedPath: string } | { ok: false; message: string; error: string }; + +// Resolves and validates searchPath; requireDirectory also checks it is a directory +export function validateSearchPath( + tempProjectPath: string, + searchPath: string | undefined, + options?: { requireDirectory?: boolean }, +): PathValidationResult { + const resolvedPath = searchPath ? path.resolve(tempProjectPath, searchPath) : tempProjectPath; + + const normalizedRoot = tempProjectPath.endsWith(path.sep) ? tempProjectPath : tempProjectPath + path.sep; + if (resolvedPath !== tempProjectPath && !resolvedPath.startsWith(normalizedRoot)) { + return { ok: false, message: `Path escapes project root: ${searchPath}`, error: "Error: Path traversal" }; + } + + if (!fs.existsSync(resolvedPath)) { + return { ok: false, message: `Search path not found: ${searchPath || "."}`, error: "Error: Path not found" }; + } + + if (options?.requireDirectory) { + let isDir: boolean; + try { + isDir = fs.statSync(resolvedPath).isDirectory(); + } catch (e) { + return { + ok: false, + message: `Path is not a directory: ${searchPath || "."}`, + error: `Error: ${(e as Error).message}`, + }; + } + if (!isDir) { + return { + ok: false, + message: `Path is not a directory: ${searchPath || "."}`, + error: "Error: Not a directory", + }; + } + } + + return { ok: true, resolvedPath }; +} + +// Strips the absolute project root prefix from a ripgrep output +export function stripRootPrefix(line: string, rootPrefix: string, tempProjectPath: string): string { + if (line.startsWith(rootPrefix)) { + return line.slice(rootPrefix.length); + } + if (line.startsWith(tempProjectPath + ":")) { + return line.slice(tempProjectPath.length + 1); + } + return line; +} \ No newline at end of file