diff --git a/build.gradle.kts b/build.gradle.kts index b591b55..4a80398 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,7 +26,7 @@ version = rootProject.scmVersion.version description = "MCP Server for GitHub Code Repositories Analysis" dependencies { - val ktorVersion = "3.1.3" + val ktorVersion = "3.0.2" val coroutinesVersion = "1.10.2" // Kotlin standard library @@ -48,6 +48,7 @@ dependencies { // Logging implementation("ch.qos.logback:logback-classic:1.5.18") implementation("org.slf4j:jul-to-slf4j:2.0.17") + // implementation("org.slf4j:slf4j-nop:2.0.9") // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") diff --git a/src/main/kotlin/mcp/code/analysis/server/Server.kt b/src/main/kotlin/mcp/code/analysis/server/Server.kt index 635ecf2..35c6eac 100644 --- a/src/main/kotlin/mcp/code/analysis/server/Server.kt +++ b/src/main/kotlin/mcp/code/analysis/server/Server.kt @@ -7,11 +7,15 @@ import io.ktor.server.engine.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.sse.* +import io.ktor.sse.ServerSentEvent import io.ktor.util.collections.* import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.* import io.modelcontextprotocol.kotlin.sdk.server.Server as SdkServer +import kotlin.text.get import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.io.asSink import kotlinx.io.asSource @@ -73,13 +77,18 @@ open class Server( embeddedServer(CIO, host = "0.0.0.0", port = port) { install(SSE) + routing { sse("/sse") { - val transport = SseServerTransport("/message", this) - val server: SdkServer = configureServer() + launch { + while (true) { + send(ServerSentEvent(event = "heartbeat")) + delay(15_000) + } + } - // For SSE, you can also add prompts/tools/resources if needed: - // server.addTool(...), server.addPrompt(...), server.addResource(...) + val transport = SseServerTransport("/message", this) + val server = configureServer() servers[transport.sessionId] = server @@ -90,16 +99,28 @@ open class Server( server.connect(transport) } + post("/message") { - logger.info("Received Message") - val sessionId: String = call.request.queryParameters["sessionId"]!! - val transport = servers[sessionId]?.transport as? SseServerTransport - if (transport == null) { - call.respond(HttpStatusCode.NotFound, "Session not found") - return@post - } + try { + + val sessionId = + call.request.queryParameters["sessionId"] + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing sessionId parameter") - transport.handlePostMessage(call) + val transport = servers[sessionId]?.transport as? SseServerTransport + if (transport == null) { + call.respond(HttpStatusCode.NotFound, "Session not found") + return@post + } + + logger.debug("Handling message for session: $sessionId") + transport.handlePostMessage(call) + + call.respond(HttpStatusCode.OK) + } catch (e: Exception) { + logger.error("Error handling message: ${e.message}", e) + call.respond(HttpStatusCode.InternalServerError, "Error handling message: ${e.message}") + } } } } @@ -112,10 +133,15 @@ open class Server( * @param port The port number on which the SSE MCP server will listen for client connections. */ open fun runSseMcpServerUsingKtorPlugin(port: Int): Unit = runBlocking { - logger.info("Starting SSE server on port $port") - logger.info("Use inspector to connect to http://localhost:$port/sse") + logger.debug("Starting SSE server on port $port") + logger.debug("Use inspector to connect to http://localhost:$port/sse") - embeddedServer(CIO, host = "0.0.0.0", port = port) { mcp { configureServer() } }.start(wait = true) + embeddedServer(CIO, host = "0.0.0.0", port = port) { + mcp { + return@mcp configureServer() + } + } + .start(wait = true) } /** diff --git a/src/main/kotlin/mcp/code/analysis/service/CodeAnalyzer.kt b/src/main/kotlin/mcp/code/analysis/service/CodeAnalyzer.kt index cf15373..1591129 100644 --- a/src/main/kotlin/mcp/code/analysis/service/CodeAnalyzer.kt +++ b/src/main/kotlin/mcp/code/analysis/service/CodeAnalyzer.kt @@ -37,8 +37,7 @@ data class CodeAnalyzer( val relativePath = file.absolutePath.substring(repoDir.absolutePath.length + 1) val lang = getLanguageFromExtension(file.extension) val content = file.readLines().joinToString("\n") - """|--- - |File: $relativePath + """|--- File: $relativePath |~~~$lang |$content |~~~""" diff --git a/src/main/kotlin/mcp/code/analysis/service/ModelContextService.kt b/src/main/kotlin/mcp/code/analysis/service/ModelContextService.kt index a9915ab..9124653 100644 --- a/src/main/kotlin/mcp/code/analysis/service/ModelContextService.kt +++ b/src/main/kotlin/mcp/code/analysis/service/ModelContextService.kt @@ -16,18 +16,11 @@ import mcp.code.analysis.config.AppConfig import org.slf4j.Logger import org.slf4j.LoggerFactory -@Serializable -data class OllamaRequest( - val model: String, - val prompt: String, - val stream: Boolean = false, - val options: OllamaOptions = OllamaOptions(), -) +@Serializable data class ChatMessage(val role: String, val content: String) -@Serializable data class OllamaOptions(val temperature: Double = 0.7, val num_predict: Int = -1) +@Serializable data class ChatRequest(val model: String, val messages: List, val stream: Boolean = false) -@Serializable -data class OllamaResponse(val model: String? = null, val response: String? = null, val done: Boolean = false) +@Serializable data class ChatResponse(val message: ChatMessage? = null) /** * Functional service for interacting with the Ollama model API. All dependencies are explicitly injected and immutable. @@ -46,12 +39,23 @@ data class ModelContextService( suspend fun generateResponse(prompt: String): String { return try { logger.info( - """|Sending request to Ollama with prompt: - |${prompt}...""" + """|Sending chat request to Ollama with prompt: + | + |$prompt...""" .trimMargin() ) - val request = OllamaRequest(model = config.modelName, prompt = prompt) - val ollamaApiUrl = "${config.modelApiUrl}/generate" + val request = + ChatRequest( + model = config.modelName, + messages = + listOf( + ChatMessage(role = "system", content = "You are a helpful assistant who explains software codebases."), + ChatMessage(role = "user", content = prompt), + ), + stream = false, + ) + + val ollamaApiUrl = "${config.modelApiUrl}/chat" val httpResponse = sendRequest(ollamaApiUrl, request) if (!httpResponse.status.isSuccess()) { @@ -59,13 +63,15 @@ data class ModelContextService( logger.error("Ollama API error: ${httpResponse.status} - $errorBody") "API error (${httpResponse.status}): $errorBody" } else { - val response = httpResponse.body() + val response = httpResponse.body() + val reply = response.message?.content?.trim() ?: "No reply received" logger.info( - """|Received response from Ollama: - |${response.response}""" + """|Received reply from Ollama: + | + |${reply}""" .trimMargin() ) - response.response ?: "No response generated" + reply } } catch (e: Exception) { logger.error("Error generating response: ${e.message}", e) @@ -76,66 +82,83 @@ data class ModelContextService( /** * Build a prompt for the model context based on the provided README file. * - * @param readme List of code snippets from the repository to analyze + * @param codeSnippets List of code snippets from the repository to analyze + * @param readme Content of the README file * @return A structured prompt for the model */ - fun buildInsightsPrompt(readme: String) = - """|You are an expert codebase analyst with deep expertise in software architecture, secure and scalable system design, and programming languages including Java, Kotlin, Python, Go, Scala, JavaScript, TypeScript, C++, Rust, Ruby, and others. + fun buildInsightsPrompt(codeSnippets: List, readme: String) = + """|You are analyzing a software codebase that includes a README file and source code files. Your task is to extract a structured summary of the codebase’s architecture, components, and relationships. | - |You will be provided with the README file of a repository. Based on the README **alone**, provide a comprehensive analysis covering the following aspects: + |Use the information provided below. | - |1. **Overall architecture** — inferred from descriptions, diagrams, setup steps, or configuration details. - |2. **Primary programming languages** — identify the main languages used and describe how they interact if applicable. - |3. **Key components and dependencies** — identify modules, services, tools, or third-party integrations and their relationships. - |4. **Design patterns** — mention any explicitly referenced or implicitly suggested architectural or code patterns. - |5. **Code quality signals** — identify any potential issues or areas of improvement (e.g., based on structure, naming, tooling). - |6. **Security considerations** — highlight any security best practices followed or missing (e.g., credential handling, auth mechanisms). - |7. **Performance considerations** — discuss caching, concurrency, resource management, or deployment implications. - |8. **Language-specific practices** — note idiomatic usage or violations of best practices for the identified languages. + |---------------------- + |README Content: | - |If any of the above are not explicitly described, provide clearly labeled **inferences** based on available information. + |~~~markdown + |${readme.replace("```", "~~~")} + |~~~ | - |Format your response in markdown using clear sections. Include direct references to specific README content where relevant. If multiple languages are involved, explain any cross-language integration points. + |---------------------- + |Code Snippets: | - |README Content: - |~~~markdown - |${readme.replace("```","~~~")} - |~~~""" + |${codeSnippets.joinToString("\n\n")} + |---------------------- + | + |For each file: + |- File name and language + |- Main classes, functions, or data structures + |- Purpose of the file (based on comments, function names, etc.) + |- Key public interfaces (functions, methods, classes) + |- If applicable, how this file connects to other parts of the codebase (e.g. imports, API usage, function calls) + | + |Use this format: + | + |### File: path/to/file.ext (Language: X) + |- **Purpose**: ... + |- **Key Components**: + | - ... + |- **Relationships**: + | - ... + | + |Repeat this for all files. Avoid speculation. Be concise and grounded in the content above. + |""" .trimMargin() /** * Build a summary prompt for the model context based on the provided code structure and snippets. * - * @param codeStructure Map representing the structure of the codebase - * @param codeSnippets List of code snippets from the repository + * @param insights From the first Prompt step * @return A structured prompt for the model */ - fun buildSummaryPrompt(codeStructure: Map, codeSnippets: List): String = - """|You are analyzing a software code repository. You are provided with the following information: + fun buildSummaryPrompt(insights: String): String = + """|You are writing a high-level but technically accurate summary of a software codebase for a developer unfamiliar with the project. | - |**Code Structure:** - |${codeStructure.entries.joinToString("\n") { "${it.key}: ${it.value}" }} + |Use the extracted structural analysis below to build the summary. | - |**Code Snippets:** - |${codeSnippets.joinToString("\n\n")} + |---------------------- | - |Using this information, write a comprehensive and accessible summary of the codebase. Your goal is to help a technically proficient developer who is new to the project quickly understand its structure and purpose. + |Structural Analysis: | - |Your summary must cover the following aspects: + |${parseInsights(insights)} + |---------------------- | - |1. **Main purpose** of the project - |2. **Core architecture and components** - |3. **Technologies and programming languages** used - |4. **Key functionality and workflows** - |5. **Potential areas for improvement or refactoring** + |You must include: | - |Where helpful, include **brief illustrative code snippets** from the examples provided to clarify key concepts, architectural decisions, or coding patterns. + |1. **Main Purpose** – what the software does, who might use it. + |2. **Architecture Overview** – main components and how they interact. + |3. **Technologies and Languages** – what languages, frameworks, or tools are used. + |4. **Key Workflows** – example flow of data, processing, or control (e.g., "HTTP request -> router -> controller -> database"). + |5. **Strengths and Weaknesses** – any observed complexity, tight coupling, lack of documentation, or reusable design patterns. | - |Format your response using markdown with clear section headings and concise, informative language. Avoid speculation beyond the provided inputs unless clearly stated as inference. - |""" + |Where helpful, include: + |- Short code snippets or function/method names (language-agnostic). + |- Architecture patterns if detected (e.g., MVC, pub/sub, monolith, microservice). + | + |Format the output using Markdown. Be concise and insightful. + """ .trimMargin() - private suspend fun sendRequest(url: String, request: OllamaRequest): HttpResponse { + private suspend fun sendRequest(url: String, request: ChatRequest): HttpResponse { return httpClient.post(url) { contentType(ContentType.Application.Json) setBody(request) @@ -162,10 +185,16 @@ data class ModelContextService( ) } install(HttpTimeout) { - requestTimeoutMillis = 10.minutes.inWholeMilliseconds - socketTimeoutMillis = 10.minutes.inWholeMilliseconds + requestTimeoutMillis = 60.minutes.inWholeMilliseconds + socketTimeoutMillis = 60.minutes.inWholeMilliseconds connectTimeoutMillis = 120_000 } } + + fun parseInsights(insights: String): String { + val lines = insights.lines() + val fileIndex = lines.indexOfFirst { it.trimStart().startsWith("### File:") } + return if (fileIndex != -1) lines.drop(fileIndex).joinToString("\n") else insights + } } } diff --git a/src/main/kotlin/mcp/code/analysis/service/RepositoryAnalysisService.kt b/src/main/kotlin/mcp/code/analysis/service/RepositoryAnalysisService.kt index 39da3b9..915d729 100644 --- a/src/main/kotlin/mcp/code/analysis/service/RepositoryAnalysisService.kt +++ b/src/main/kotlin/mcp/code/analysis/service/RepositoryAnalysisService.kt @@ -1,7 +1,5 @@ package mcp.code.analysis.service -import kotlinx.coroutines.* - /** Service for analyzing Git repositories. */ data class RepositoryAnalysisService( private val gitService: GitService = GitService(), @@ -15,26 +13,20 @@ data class RepositoryAnalysisService( * @param branch The branch of the repository to analyze. * @return A summary of the analysis results. */ - fun analyzeRepository(repoUrl: String, branch: String): String { + suspend fun analyzeRepository(repoUrl: String, branch: String): String { return try { val repoDir = gitService.cloneRepository(repoUrl, branch) - val readmeContent = codeAnalyzer.findReadmeFile(repoDir) - val codeStructure = codeAnalyzer.analyzeStructure(repoDir) + val readme = codeAnalyzer.findReadmeFile(repoDir) val codeSnippets = codeAnalyzer.collectAllCodeSnippets(repoDir) - val insightsPrompt = modelContextService.buildInsightsPrompt(readmeContent) - val summaryPrompt = modelContextService.buildSummaryPrompt(codeStructure, codeSnippets) + val insightsPrompt = modelContextService.buildInsightsPrompt(codeSnippets, readme) + val insightsResponse = modelContextService.generateResponse(insightsPrompt) + + val summaryPrompt = modelContextService.buildSummaryPrompt(insightsResponse) + val summaryResponse = modelContextService.generateResponse(summaryPrompt) - runBlocking { - val insights = async { modelContextService.generateResponse(insightsPrompt) } - val summary = async { modelContextService.generateResponse(summaryPrompt) } - """|${insights.await()} - | - |${summary.await()} - |""" - .trimMargin() - } + summaryResponse } catch (e: Exception) { throw Exception("Error analyzing repository: ${e.message}", e) } diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 8c1a8f1..ecb98f2 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -1,6 +1,6 @@ - + System.err @@ -28,8 +28,8 @@ - + - + diff --git a/src/test/kotlin/mcp/code/analysis/service/ModelContextServiceTest.kt b/src/test/kotlin/mcp/code/analysis/service/ModelContextServiceTest.kt index 69ea0f6..d80c1f6 100644 --- a/src/test/kotlin/mcp/code/analysis/service/ModelContextServiceTest.kt +++ b/src/test/kotlin/mcp/code/analysis/service/ModelContextServiceTest.kt @@ -36,13 +36,8 @@ class ModelContextServiceTest { val expectedResult: String, ) - // Test cases for generateSummary function - data class SummaryTestCase( - val name: String, - val codeStructure: Map, - val codeSnippets: List, - val expectedPromptContains: List, - ) + // Test cases for buildSummaryPrompt function + data class SummaryTestCase(val name: String, val insights: String, val expectedPromptContains: List) @BeforeEach fun setUp() { @@ -71,7 +66,7 @@ class ModelContextServiceTest { engine { addHandler { request -> respond( - content = """{"model":"test-model","response":"Generated test response","done":true}""", + content = """{"message":{"role":"assistant","content":"Generated test response"}}""", status = HttpStatusCode.OK, headers = headersOf(HttpHeaders.ContentType, "application/json"), ) @@ -133,7 +128,7 @@ class ModelContextServiceTest { testCases.forEach { testCase -> // Act - val prompt = service.buildInsightsPrompt(testCase.readmeContent) + val prompt = service.buildInsightsPrompt(emptyList(), testCase.readmeContent) // Assert testCase.expectedContains.forEach { expected -> @@ -153,19 +148,40 @@ class ModelContextServiceTest { ResponseTestCase( name = "simple prompt", prompt = "Test prompt", - mockResponse = """{"model":"test-model","response":"Generated test response","done":true}""", + mockResponse = + """|{ + | "message": { + | "role": "assistant", + | "content": "Generated test response" + | } + |}""" + .trimMargin(), expectedResult = "Generated test response", ), ResponseTestCase( name = "empty response", prompt = "Empty test", - mockResponse = """{"model":"test-model","response":"","done":true}""", + mockResponse = + """|{ + | "message": { + | "role": "assistant", + | "content": "" + | } + |}""" + .trimMargin(), expectedResult = "", ), ResponseTestCase( name = "special characters", prompt = "Special chars test", - mockResponse = """{"model":"test-model","response":"Response with \"quotes\" and \n newlines","done":true}""", + mockResponse = + """|{ + | "message": { + | "role": "assistant", + | "content": "Response with \"quotes\" and \n newlines" + | } + |}""" + .trimMargin(), expectedResult = "Response with \"quotes\" and \n newlines", ), ) @@ -201,77 +217,63 @@ class ModelContextServiceTest { } @Test - fun `test generateSummary builds combined prompt with code and insights`() = runBlocking { + fun `test buildSummaryPrompt builds prompt with insights`() = runBlocking { // Arrange val testCases = listOf( SummaryTestCase( - name = "simple project", - codeStructure = mapOf("main.kt" to mapOf("language" to "kotlin")), - codeSnippets = - listOf( - """|File: main.kt - |~~~kotlin - |fun main() {} - |~~~""" - .trimMargin() - ), - expectedPromptContains = listOf("Code Snippets:", "fun main() {}"), - ), - SummaryTestCase( - name = "complex project", - codeStructure = - mapOf( - "src" to mapOf("main.kt" to mapOf("language" to "kotlin"), "util.kt" to mapOf("language" to "kotlin")) - ), - codeSnippets = - listOf( - """|File: src/main.kt - |~~~kotlin - |fun main() {} - |~~~""" - .trimMargin(), - """|File: src/util.kt - |~~~kotlin - |fun util() {} - |~~~""" - .trimMargin(), - ), - expectedPromptContains = listOf("Code Snippets:", "fun main() {}", "fun util() {}"), + name = "simple insights", + insights = "Basic code analysis with some insights", + expectedPromptContains = listOf("Structural Analysis:", "Basic code analysis with some insights"), ), SummaryTestCase( - name = "empty project", - codeStructure = emptyMap(), - codeSnippets = emptyList(), - expectedPromptContains = listOf("Code Snippets:"), + name = "complex insights with file analysis", + insights = + """|### File: src/main.kt (Language: Kotlin) + |- **Purpose**: Main entry point + |- **Key Components**: main function + | + |### File: src/util.kt (Language: Kotlin) + |- **Purpose**: Utility functions + |""" + .trimMargin(), + expectedPromptContains = listOf("Structural Analysis:", "File: src/main.kt", "File: src/util.kt"), ), + SummaryTestCase(name = "empty insights", insights = "", expectedPromptContains = listOf("Structural Analysis:")), ) testCases.forEach { testCase -> - val mockService = mockk() - coEvery { mockService.generateResponse(any()) } returns "Mocked summary response" - coEvery { mockService.buildSummaryPrompt(any(), any()) } coAnswers - { - val codeStructure = firstArg>() - val codeSnippets = secondArg>() - - // Act - val prompt = service.buildSummaryPrompt(codeStructure, codeSnippets) - - // Assert - testCase.expectedPromptContains.forEach { expected -> - assertTrue(prompt.contains(expected), "Prompt for test '${testCase.name}' should contain '$expected'") - } - - "Mocked summary response" - } - // Act - val summary = mockService.buildSummaryPrompt(testCase.codeStructure, testCase.codeSnippets) + val prompt = service.buildSummaryPrompt(testCase.insights) // Assert - assertEquals("Mocked summary response", summary) - coVerify { mockService.buildSummaryPrompt(testCase.codeStructure, testCase.codeSnippets) } + testCase.expectedPromptContains.forEach { expected -> + assertTrue(prompt.contains(expected), "Prompt for test '${testCase.name}' should contain '$expected'") + } } } + + @Test + fun `test parseInsights extracts file sections`() { + // Arrange + val insights = + """|Some general information + | + |### File: src/main.kt (Language: Kotlin) + |- **Purpose**: Main entry point + | + |### File: src/util.kt (Language: Kotlin) + |- **Purpose**: Utility functions + |""" + .trimMargin() + + // Act + val parsedInsights = ModelContextService.parseInsights(insights) + + // Assert + assertTrue(parsedInsights.startsWith("### File:")) + assertTrue(parsedInsights.contains("src/main.kt")) + assertTrue(parsedInsights.contains("src/util.kt")) + assertFalse(parsedInsights.contains("Some general information")) + } } diff --git a/src/test/kotlin/mcp/code/analysis/service/RepositoryAnalysisServiceTest.kt b/src/test/kotlin/mcp/code/analysis/service/RepositoryAnalysisServiceTest.kt index 42c1c9a..06341af 100644 --- a/src/test/kotlin/mcp/code/analysis/service/RepositoryAnalysisServiceTest.kt +++ b/src/test/kotlin/mcp/code/analysis/service/RepositoryAnalysisServiceTest.kt @@ -7,7 +7,6 @@ import io.mockk.verify import java.io.File import java.lang.Exception import kotlin.test.assertEquals -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach @@ -40,188 +39,155 @@ class RepositoryAnalysisServiceTest { @Test fun `analyzeRepository should return analysis result`() = runTest { // Arrange - val repoUrl = "https://github.com/user/repo" + val repoUrl = "https://github.com/test/repo" val branch = "main" val clonedRepo = File(tempDir, "repo") - val readmeContent = "# Test Repository" - val codeSnippets = - listOf( - """|--- - |File: src/Main.kt - |~~~kotlin - |fun main() {} - |~~~""" - .trimMargin() - ) - val insightsPrompt = "insights prompt" - val summaryPrompt = "summary prompt" - val insightsResponse = "insights" - val summaryResponse = "summary" - + val readme = "# Test Repository" + val codeSnippets = listOf("--- File: src/main.kt\n~~~kotlin\nfun main() {}\n~~~") + val insightsPrompt = "Generated insights prompt" + val insightsResponse = "File analysis..." + val summaryPrompt = "Generated summary prompt" + val summaryResponse = "Repository summary" + + // Mock behavior every { gitService.cloneRepository(repoUrl, branch) } returns clonedRepo - every { codeAnalyzer.findReadmeFile(clonedRepo) } returns readmeContent - every { modelContextService.buildInsightsPrompt(readmeContent) } returns insightsPrompt - every { modelContextService.buildSummaryPrompt(any(), any()) } returns summaryPrompt - every { codeAnalyzer.analyzeStructure(clonedRepo) } returns emptyMap() + every { codeAnalyzer.findReadmeFile(clonedRepo) } returns readme every { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } returns codeSnippets + every { modelContextService.buildInsightsPrompt(codeSnippets, readme) } returns insightsPrompt coEvery { modelContextService.generateResponse(insightsPrompt) } returns insightsResponse + every { modelContextService.buildSummaryPrompt(insightsResponse) } returns summaryPrompt coEvery { modelContextService.generateResponse(summaryPrompt) } returns summaryResponse // Act val result = repositoryAnalysisService.analyzeRepository(repoUrl, branch) // Assert - assertTrue(result.contains(insightsResponse)) - assertTrue(result.contains(summaryResponse)) + assertEquals(summaryResponse, result) verify { gitService.cloneRepository(repoUrl, branch) } verify { codeAnalyzer.findReadmeFile(clonedRepo) } verify { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } - verify { runBlocking { modelContextService.generateResponse(any()) } } + verify { modelContextService.buildInsightsPrompt(codeSnippets, readme) } + verify { modelContextService.buildSummaryPrompt(insightsResponse) } } @Test fun `analyzeRepository should handle git errors`() = runTest { // Arrange - val repoUrl = "https://github.com/user/repo" + val repoUrl = "https://github.com/test/repo" val branch = "main" + val errorMessage = "Repository not found" - every { gitService.cloneRepository(repoUrl, branch) } throws Exception("Error cloning repository") + every { gitService.cloneRepository(repoUrl, branch) } throws Exception(errorMessage) // Act & Assert val exception = assertThrows { repositoryAnalysisService.analyzeRepository(repoUrl, branch) } - assert(exception.message?.contains("Error cloning repository") == true) + + assertTrue(exception.message!!.contains("Error analyzing repository")) + assertTrue(exception.cause?.message!!.contains(errorMessage)) } @Test - fun `analyzeRepository should handle code analysis errors`() { + fun `analyzeRepository should handle code analysis errors`() = runTest { // Arrange - val repoUrl = "https://github.com/user/repo" + val repoUrl = "https://github.com/test/repo" val branch = "main" val clonedRepo = File(tempDir, "repo") + val errorMessage = "Error processing code files" every { gitService.cloneRepository(repoUrl, branch) } returns clonedRepo - every { codeAnalyzer.findReadmeFile(clonedRepo) } throws Exception("Error analyzing repository code") + every { codeAnalyzer.findReadmeFile(clonedRepo) } returns "README content" + every { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } throws Exception(errorMessage) // Act & Assert - val exception = - assertThrows { runBlocking { repositoryAnalysisService.analyzeRepository(repoUrl, branch) } } + val exception = assertThrows { repositoryAnalysisService.analyzeRepository(repoUrl, branch) } - assert(exception.message?.contains("Error analyzing repository code") == true) + assertTrue(exception.message!!.contains("Error analyzing repository")) + assertTrue(exception.cause?.message!!.contains(errorMessage)) } @Test - fun `analyzeRepository should handle model service errors`() { + fun `analyzeRepository should handle model service errors`() = runTest { // Arrange - val repoUrl = "https://github.com/user/repo" + val repoUrl = "https://github.com/test/repo" val branch = "main" val clonedRepo = File(tempDir, "repo") - val readmeContent = "# Test Repository" - val codeSnippets = - listOf( - """|--- - |File: src/Main.kt - |~~~kotlin - |fun main() {} - |~~~""" - .trimMargin() - ) + val readme = "# Test Repository" + val codeSnippets = listOf("--- File: src/main.kt\n~~~kotlin\nfun main() {}\n~~~") + val insightsPrompt = "Generated insights prompt" + val errorMessage = "Model API error" every { gitService.cloneRepository(repoUrl, branch) } returns clonedRepo - every { codeAnalyzer.findReadmeFile(clonedRepo) } returns readmeContent + every { codeAnalyzer.findReadmeFile(clonedRepo) } returns readme every { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } returns codeSnippets - coEvery { modelContextService.generateResponse(any()) } throws Exception("Error analyzing repository") + every { modelContextService.buildInsightsPrompt(codeSnippets, readme) } returns insightsPrompt + coEvery { modelContextService.generateResponse(insightsPrompt) } throws Exception(errorMessage) // Act & Assert - val exception = - assertThrows { runBlocking { repositoryAnalysisService.analyzeRepository(repoUrl, branch) } } + val exception = assertThrows { repositoryAnalysisService.analyzeRepository(repoUrl, branch) } - assert(exception.message?.contains("Error analyzing repository") == true) + assertTrue(exception.message!!.contains("Error analyzing repository")) + assertTrue(exception.cause?.message!!.contains(errorMessage)) } @Test fun `analyzeRepository should handle empty code snippets`() = runTest { // Arrange - val repoUrl = "https://github.com/user/repo" + val repoUrl = "https://github.com/test/repo" val branch = "main" val clonedRepo = File(tempDir, "repo") - val readmeContent = "# Test Repository" - val emptySnippets = emptyList() - val insightsPrompt = "Insights prompt" - val summaryPrompt = "Summary prompt" - val modelResponse = "Repository Analysis" - + val readme = "# Test Repository" + val emptyCodeSnippets = emptyList() + val insightsPrompt = "Generated insights prompt with empty snippets" + val insightsResponse = "Limited file analysis due to no code snippets" + val summaryPrompt = "Generated summary prompt" + val summaryResponse = "Limited repository summary" + + // Mock behavior every { gitService.cloneRepository(repoUrl, branch) } returns clonedRepo - every { codeAnalyzer.findReadmeFile(clonedRepo) } returns readmeContent - every { codeAnalyzer.analyzeStructure(clonedRepo) } returns emptyMap() - every { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } returns emptySnippets - every { modelContextService.buildInsightsPrompt(readmeContent) } returns insightsPrompt - every { modelContextService.buildSummaryPrompt(any(), any()) } returns summaryPrompt - coEvery { modelContextService.generateResponse(any()) } returns modelResponse + every { codeAnalyzer.findReadmeFile(clonedRepo) } returns readme + every { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } returns emptyCodeSnippets + every { modelContextService.buildInsightsPrompt(emptyCodeSnippets, readme) } returns insightsPrompt + coEvery { modelContextService.generateResponse(insightsPrompt) } returns insightsResponse + every { modelContextService.buildSummaryPrompt(insightsResponse) } returns summaryPrompt + coEvery { modelContextService.generateResponse(summaryPrompt) } returns summaryResponse // Act val result = repositoryAnalysisService.analyzeRepository(repoUrl, branch) // Assert - assertEquals( - """|$modelResponse - | - |$modelResponse - |""" - .trimMargin(), - result, - ) + assertEquals(summaryResponse, result) verify { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } - verify { - runBlocking { modelContextService.generateResponse("Insights prompt") } - runBlocking { modelContextService.generateResponse("Summary prompt") } - } + verify { modelContextService.buildInsightsPrompt(emptyCodeSnippets, readme) } } @Test fun `analyzeRepository should handle missing README`() = runTest { // Arrange - val repoUrl = "https://github.com/user/repo" + val repoUrl = "https://github.com/test/repo" val branch = "main" val clonedRepo = File(tempDir, "repo") val noReadme = "No README content available." - val codeSnippets = - listOf( - """"|--- - |File: src/Main.kt - |~~~kotlin - |fun main() {} - |~~~""" - .trimMargin() - ) - val modelResponse = "Repository Analysis: Kotlin project without README" - val insightsPrompt = "No README content available" - val summaryPrompt = "Summary prompt" + val codeSnippets = listOf("--- File: src/main.kt\n~~~kotlin\nfun main() {}\n~~~") + val insightsPrompt = "Generated insights prompt without readme" + val insightsResponse = "File analysis without readme context" + val summaryPrompt = "Generated summary prompt" + val summaryResponse = "Repository summary" + // Mock behavior every { gitService.cloneRepository(repoUrl, branch) } returns clonedRepo every { codeAnalyzer.findReadmeFile(clonedRepo) } returns noReadme - every { codeAnalyzer.analyzeStructure(clonedRepo) } returns emptyMap() every { codeAnalyzer.collectAllCodeSnippets(clonedRepo) } returns codeSnippets - every { modelContextService.buildInsightsPrompt(noReadme) } returns insightsPrompt - every { modelContextService.buildSummaryPrompt(any(), any()) } returns summaryPrompt - coEvery { modelContextService.generateResponse(any()) } returns modelResponse + every { modelContextService.buildInsightsPrompt(codeSnippets, noReadme) } returns insightsPrompt + coEvery { modelContextService.generateResponse(insightsPrompt) } returns insightsResponse + every { modelContextService.buildSummaryPrompt(insightsResponse) } returns summaryPrompt + coEvery { modelContextService.generateResponse(summaryPrompt) } returns summaryResponse // Act val result = repositoryAnalysisService.analyzeRepository(repoUrl, branch) // Assert - assertEquals( - """|$modelResponse - | - |$modelResponse - |""" - .trimMargin(), - result, - ) + assertEquals(summaryResponse, result) verify { codeAnalyzer.findReadmeFile(clonedRepo) } - verify { - runBlocking { - modelContextService.generateResponse(match { prompt -> prompt.contains("No README content available") }) - } - } + verify { modelContextService.buildInsightsPrompt(codeSnippets, noReadme) } } }