From 6bcbb15c68f81d76c6b4fde67a2b530254284f98 Mon Sep 17 00:00:00 2001 From: rostislav Date: Fri, 22 May 2026 05:23:40 +0300 Subject: [PATCH] feat: Enhance AI command processing with fallback handling and null checks in summarization and asking features --- .../aiclient/AiCommandClient.java | 15 +- .../aiclient/AiCommandClientTest.java | 59 ++++++++ .../command/AskCommandProcessor.java | 35 ++++- .../command/SummarizeCommandProcessor.java | 48 ++++-- .../command/AskCommandProcessorTest.java | 143 ++++++++++++++++++ .../SummarizeCommandProcessorTest.java | 140 +++++++++++++++++ 6 files changed, 422 insertions(+), 18 deletions(-) create mode 100644 java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/AskCommandProcessorTest.java create mode 100644 java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/SummarizeCommandProcessorTest.java diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/aiclient/AiCommandClient.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/aiclient/AiCommandClient.java index d50e8b5f..d3548a65 100644 --- a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/aiclient/AiCommandClient.java +++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/aiclient/AiCommandClient.java @@ -41,9 +41,9 @@ public SummarizeResult summarize(SummarizeRequest request, Consumer finalResult = executeAsyncJob(jobId, "summarize", request, eventHandler); return new SummarizeResult( - (String) finalResult.getOrDefault("summary", ""), - (String) finalResult.getOrDefault("diagram", ""), - (String) finalResult.getOrDefault("diagramType", "MERMAID")); + stringValue(finalResult, "summary", ""), + stringValue(finalResult, "diagram", ""), + stringValue(finalResult, "diagramType", "MERMAID")); } /** @@ -56,7 +56,7 @@ public AskResult ask(AskRequest request, Consumer> eventHand Map finalResult = executeAsyncJob(jobId, "ask", request, eventHandler); - return new AskResult((String) finalResult.getOrDefault("answer", "")); + return new AskResult(stringValue(finalResult, "answer", "")); } /** @@ -69,7 +69,12 @@ public ReviewResult review(ReviewRequest request, Consumer> Map finalResult = executeAsyncJob(jobId, "review", request, eventHandler); - return new ReviewResult((String) finalResult.getOrDefault("review", "")); + return new ReviewResult(stringValue(finalResult, "review", "")); + } + + private static String stringValue(Map result, String key, String defaultValue) { + Object value = result.get(key); + return value == null ? defaultValue : String.valueOf(value); } @SuppressWarnings("unchecked") diff --git a/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/aiclient/AiCommandClientTest.java b/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/aiclient/AiCommandClientTest.java index da4743bd..d614992a 100644 --- a/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/aiclient/AiCommandClientTest.java +++ b/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/aiclient/AiCommandClientTest.java @@ -12,6 +12,7 @@ import org.rostilos.codecrow.queue.RedisQueueService; import java.io.IOException; +import java.util.HashMap; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -101,6 +102,28 @@ void shouldThrowIOExceptionWhenErrorEvent() throws Exception { .isInstanceOf(IOException.class) .hasMessageContaining("AI service returned error: Rate limit exceeded"); } + + @Test + @DisplayName("should default null summarize result fields") + void shouldDefaultNullSummarizeResultFields() throws Exception { + Map resultPayload = new HashMap<>(); + resultPayload.put("summary", null); + resultPayload.put("diagram", null); + resultPayload.put("diagramType", null); + + Map finalEvent = new HashMap<>(); + finalEvent.put("type", "final"); + finalEvent.put("result", resultPayload); + + when(queueService.rightPop(anyString(), anyLong())) + .thenReturn(objectMapper.writeValueAsString(finalEvent)); + + AiCommandClient.SummarizeResult result = client.summarize(createSummarizeRequest(), null); + + assertThat(result.summary()).isEmpty(); + assertThat(result.diagram()).isEmpty(); + assertThat(result.diagramType()).isEqualTo("MERMAID"); + } } @Nested @@ -121,6 +144,24 @@ void shouldSuccessfullyAnswerQuestion() throws Exception { assertThat(result.answer()).isEqualTo("This code implements a REST API endpoint"); } + + @Test + @DisplayName("should default null answer field") + void shouldDefaultNullAnswerField() throws Exception { + Map resultPayload = new HashMap<>(); + resultPayload.put("answer", null); + + Map finalEvent = new HashMap<>(); + finalEvent.put("type", "final"); + finalEvent.put("result", resultPayload); + + when(queueService.rightPop(anyString(), anyLong())) + .thenReturn(objectMapper.writeValueAsString(finalEvent)); + + AiCommandClient.AskResult result = client.ask(createAskRequest(), null); + + assertThat(result.answer()).isEmpty(); + } } @Nested @@ -141,5 +182,23 @@ void shouldSuccessfullyReviewCode() throws Exception { assertThat(result.review()).isEqualTo("## Code Review\n\nLooks good!"); } + + @Test + @DisplayName("should default null review field") + void shouldDefaultNullReviewField() throws Exception { + Map resultPayload = new HashMap<>(); + resultPayload.put("review", null); + + Map finalEvent = new HashMap<>(); + finalEvent.put("type", "final"); + finalEvent.put("result", resultPayload); + + when(queueService.rightPop(anyString(), anyLong())) + .thenReturn(objectMapper.writeValueAsString(finalEvent)); + + AiCommandClient.ReviewResult result = client.review(createReviewRequest(), null); + + assertThat(result.review()).isEmpty(); + } } } diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/AskCommandProcessor.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/AskCommandProcessor.java index 4527c2bf..b7ddd271 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/AskCommandProcessor.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/AskCommandProcessor.java @@ -297,7 +297,17 @@ private String generateAnswer( ); log.info("AI answer generated successfully"); - return result.answer(); + String answer = result != null ? result.answer() : null; + if (!hasUsableAnswer(answer)) { + log.warn( + "AI ask result did not include usable answer content for project={}, PR={}; using fallback answer", + project.getId(), + payload.pullRequestId() + ); + return generatePlaceholderAnswer(question, context, contextData); + } + + return answer; } catch (IOException e) { log.error("Failed to generate answer via AI: {}", e.getMessage(), e); throw new AiGenerationException("AI service failed: " + e.getMessage(), e); @@ -462,7 +472,9 @@ private String generatePlaceholderAnswer( if (!contextData.analysisInfo().isBlank()) { answer.append(contextData.analysisInfo()); } else { - answer.append("No analysis data found for this PR yet.\n"); + answer.append("I couldn't generate a detailed AI answer for this PR.\n\n"); + answer.append("No analysis data was found for this PR yet. "); + answer.append("Run `/codecrow analyze` first, then retry your question.\n"); } } case ANALYSIS_RELATED -> { @@ -485,7 +497,7 @@ private String generatePlaceholderAnswer( } default -> { answer.append("**Answer**\n\n"); - answer.append("_Full AI-powered answers are pending implementation._\n\n"); + answer.append("I couldn't generate a detailed AI answer for this question.\n\n"); answer.append("Your question: \"").append(truncate(question, 200)).append("\"\n\n"); answer.append("For now, you can:\n"); answer.append("- Use `/codecrow analyze` to run PR analysis\n"); @@ -501,7 +513,11 @@ private String formatResponse(String answer, QuestionContext context) { StringBuilder sb = new StringBuilder(); sb.append("\n"); sb.append("## 💬 CodeCrow Answer\n\n"); - sb.append(answer); + if (hasUsableAnswer(answer)) { + sb.append(answer); + } else { + sb.append("I couldn't generate an answer. Please try rephrasing your question."); + } String content = sb.toString(); if (content.length() > MAX_RESPONSE_LENGTH) { @@ -517,6 +533,17 @@ private String truncate(String text, int maxLength) { return text.substring(0, maxLength) + "..."; } + private boolean hasUsableAnswer(String answer) { + if (answer == null || answer.isBlank()) { + return false; + } + + String normalized = answer.trim(); + return !"No output generated".equalsIgnoreCase(normalized) + && !"null".equalsIgnoreCase(normalized) + && !"none".equalsIgnoreCase(normalized); + } + /** * Types of questions that can be asked. */ diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/SummarizeCommandProcessor.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/SummarizeCommandProcessor.java index 88a41857..08f38b9d 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/SummarizeCommandProcessor.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/SummarizeCommandProcessor.java @@ -217,11 +217,19 @@ private SummaryResult generateSummary( log.info("AI summarization completed successfully"); - // Convert diagram type from string to enum - PrSummarizeCache.DiagramType resultDiagramType = - "ASCII".equalsIgnoreCase(result.diagramType()) - ? PrSummarizeCache.DiagramType.ASCII - : PrSummarizeCache.DiagramType.MERMAID; + PrSummarizeCache.DiagramType resultDiagramType = resolveDiagramType( + result != null ? result.diagramType() : null, + diagramType + ); + + if (result == null || !hasText(result.summary())) { + log.warn( + "AI summarize result did not include summary content for project={}, PR={}; using fallback summary", + project.getId(), + payload.pullRequestId() + ); + return generateFallbackSummary(payload, resultDiagramType); + } return new SummaryResult( result.summary(), @@ -390,13 +398,13 @@ private String generatePlaceholderSummary(WebhookPayload payload, PrSummarizeCac .append("`.\n\n"); sb.append("### Key Changes\n"); - sb.append("_Summary generation via AI is pending implementation._\n\n"); + sb.append("_CodeCrow could not generate a detailed AI summary from the AI response._\n\n"); sb.append("### Impact Analysis\n"); - sb.append("_Analysis pending._\n\n"); + sb.append("_Check the job logs for the underlying AI response details._\n\n"); sb.append("### Recommendations\n"); - sb.append("_Recommendations pending._\n\n"); + sb.append("_Review the pull request manually before merging._\n\n"); return sb.toString(); } @@ -443,11 +451,16 @@ private PrSummarizeCache cacheResult( WebhookPayload payload, SummaryResult summaryResult ) { + String summaryContent = summaryResult != null ? summaryResult.summaryContent() : null; + if (!hasText(summaryContent)) { + throw new IllegalStateException("Cannot cache empty summary content"); + } + PrSummarizeCache cache = new PrSummarizeCache(); cache.setProject(project); cache.setCommitHash(payload.commitHash()); cache.setPrNumber(Long.parseLong(payload.pullRequestId())); - cache.setSummaryContent(summaryResult.summaryContent()); + cache.setSummaryContent(summaryContent); cache.setDiagramContent(summaryResult.diagramContent()); cache.setDiagramType(summaryResult.diagramType()); cache.setExpiresAt(OffsetDateTime.now().plusHours(CACHE_TTL_HOURS)); @@ -455,6 +468,23 @@ private PrSummarizeCache cacheResult( return summarizeCacheRepository.save(cache); } + private PrSummarizeCache.DiagramType resolveDiagramType( + String diagramType, + PrSummarizeCache.DiagramType defaultDiagramType + ) { + if ("ASCII".equalsIgnoreCase(diagramType)) { + return PrSummarizeCache.DiagramType.ASCII; + } + if ("MERMAID".equalsIgnoreCase(diagramType)) { + return PrSummarizeCache.DiagramType.MERMAID; + } + return defaultDiagramType; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + private String formatSummaryForPosting(SummaryResult result, PrSummarizeCache.DiagramType diagramType) { StringBuilder sb = new StringBuilder(); sb.append("\n\n"); diff --git a/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/AskCommandProcessorTest.java b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/AskCommandProcessorTest.java new file mode 100644 index 00000000..2b4d852d --- /dev/null +++ b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/AskCommandProcessorTest.java @@ -0,0 +1,143 @@ +package org.rostilos.codecrow.pipelineagent.generic.processor.command; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.rostilos.codecrow.analysisengine.aiclient.AiCommandClient; +import org.rostilos.codecrow.analysisengine.aiclient.AiCommandClient.AskRequest; +import org.rostilos.codecrow.analysisengine.aiclient.AiCommandClient.AskResult; +import org.rostilos.codecrow.analysisengine.service.PromptSanitizationService; +import org.rostilos.codecrow.core.model.ai.AIConnection; +import org.rostilos.codecrow.core.model.ai.AIProviderKey; +import org.rostilos.codecrow.core.model.project.Project; +import org.rostilos.codecrow.core.model.project.ProjectAiConnectionBinding; +import org.rostilos.codecrow.core.model.vcs.EVcsConnectionType; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; +import org.rostilos.codecrow.core.model.vcs.VcsConnection; +import org.rostilos.codecrow.core.model.vcs.VcsRepoBinding; +import org.rostilos.codecrow.core.service.CodeAnalysisService; +import org.rostilos.codecrow.pipelineagent.generic.dto.webhook.WebhookPayload; +import org.rostilos.codecrow.pipelineagent.generic.webhookhandler.WebhookHandler.WebhookResult; +import org.rostilos.codecrow.security.oauth.TokenEncryptionService; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AskCommandProcessor") +class AskCommandProcessorTest { + + @Mock private CodeAnalysisService codeAnalysisService; + @Mock private AiCommandClient aiCommandClient; + @Mock private TokenEncryptionService tokenEncryptionService; + + private AskCommandProcessor processor; + + @BeforeEach + void setUp() { + processor = new AskCommandProcessor( + codeAnalysisService, + new PromptSanitizationService(), + aiCommandClient, + tokenEncryptionService + ); + } + + @Test + @DisplayName("should use fallback response when AI answer is not usable") + void shouldUseFallbackResponseWhenAiAnswerIsNotUsable() throws Exception { + assertFallbackResponseWhenAiAnswerIsNotUsable("No output generated"); + } + + @Test + @DisplayName("should use fallback response when AI answer is literal null") + void shouldUseFallbackResponseWhenAiAnswerIsLiteralNull() throws Exception { + assertFallbackResponseWhenAiAnswerIsNotUsable("null"); + } + + private void assertFallbackResponseWhenAiAnswerIsNotUsable(String aiAnswer) throws Exception { + Project project = createProject(); + WebhookPayload payload = createPayload(); + + when(codeAnalysisService.getCodeAnalysisCache(42L, "abc123", 7L)).thenReturn(Optional.empty()); + when(tokenEncryptionService.decrypt("encrypted-ai-key")).thenReturn("ai-key"); + when(tokenEncryptionService.decrypt("encrypted-vcs-token")).thenReturn("vcs-token"); + when(aiCommandClient.ask( + any(AskRequest.class), + any() + )).thenReturn(new AskResult(aiAnswer)); + + Consumer> eventConsumer = event -> {}; + WebhookResult result = processor.process( + payload, + project, + eventConsumer, + Map.of("question", "describe this PR and issues") + ); + + assertThat(result.success()).isTrue(); + assertThat(result.message()).isEqualTo("Answer generated successfully"); + assertThat(result.data().get("content")) + .asString() + .contains("I couldn't generate a detailed AI answer for this PR") + .contains("Run `/codecrow analyze` first") + .doesNotContain(aiAnswer); + } + + private Project createProject() { + Project project = new Project(); + ReflectionTestUtils.setField(project, "id", 42L); + project.setName("Test Project"); + project.setNamespace("test-project"); + + AIConnection aiConnection = new AIConnection(); + aiConnection.setProviderKey(AIProviderKey.OPENAI); + aiConnection.setAiModel("gpt-4"); + aiConnection.setApiKeyEncrypted("encrypted-ai-key"); + + ProjectAiConnectionBinding aiBinding = new ProjectAiConnectionBinding(); + aiBinding.setProject(project); + aiBinding.setAiConnection(aiConnection); + project.setAiConnectionBinding(aiBinding); + + VcsConnection vcsConnection = new VcsConnection(); + vcsConnection.setProviderType(EVcsProvider.GITHUB); + vcsConnection.setConnectionType(EVcsConnectionType.ACCESS_TOKEN); + vcsConnection.setAccessToken("encrypted-vcs-token"); + + VcsRepoBinding vcsBinding = new VcsRepoBinding(); + vcsBinding.setProject(project); + vcsBinding.setVcsConnection(vcsConnection); + vcsBinding.setProvider(EVcsProvider.GITHUB); + vcsBinding.setExternalRepoId("repo-id"); + vcsBinding.setExternalNamespace("codecrow"); + vcsBinding.setExternalRepoSlug("codecrow-public"); + project.setVcsRepoBinding(vcsBinding); + + return project; + } + + private WebhookPayload createPayload() { + return new WebhookPayload( + EVcsProvider.GITHUB, + "issue_comment", + "repo-id", + "codecrow-public", + "codecrow", + "7", + "feature/ask", + "main", + "abc123", + null + ); + } +} diff --git a/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/SummarizeCommandProcessorTest.java b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/SummarizeCommandProcessorTest.java new file mode 100644 index 00000000..c6fbf94a --- /dev/null +++ b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/SummarizeCommandProcessorTest.java @@ -0,0 +1,140 @@ +package org.rostilos.codecrow.pipelineagent.generic.processor.command; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.rostilos.codecrow.analysisengine.aiclient.AiCommandClient; +import org.rostilos.codecrow.analysisengine.aiclient.AiCommandClient.SummarizeRequest; +import org.rostilos.codecrow.analysisengine.aiclient.AiCommandClient.SummarizeResult; +import org.rostilos.codecrow.analysisengine.service.vcs.VcsReportingService; +import org.rostilos.codecrow.analysisengine.service.vcs.VcsServiceFactory; +import org.rostilos.codecrow.core.model.ai.AIConnection; +import org.rostilos.codecrow.core.model.ai.AIProviderKey; +import org.rostilos.codecrow.core.model.codeanalysis.PrSummarizeCache; +import org.rostilos.codecrow.core.model.project.Project; +import org.rostilos.codecrow.core.model.project.ProjectAiConnectionBinding; +import org.rostilos.codecrow.core.model.project.ProjectVcsConnectionBinding; +import org.rostilos.codecrow.core.model.vcs.EVcsConnectionType; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; +import org.rostilos.codecrow.core.model.vcs.VcsConnection; +import org.rostilos.codecrow.core.persistence.repository.codeanalysis.PrSummarizeCacheRepository; +import org.rostilos.codecrow.pipelineagent.generic.dto.webhook.WebhookPayload; +import org.rostilos.codecrow.pipelineagent.generic.webhookhandler.WebhookHandler.WebhookResult; +import org.rostilos.codecrow.security.oauth.TokenEncryptionService; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Map; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SummarizeCommandProcessor") +class SummarizeCommandProcessorTest { + + @Mock private VcsServiceFactory vcsServiceFactory; + @Mock private PrSummarizeCacheRepository summarizeCacheRepository; + @Mock private AiCommandClient aiCommandClient; + @Mock private TokenEncryptionService tokenEncryptionService; + @Mock private VcsReportingService reportingService; + + private SummarizeCommandProcessor processor; + + @BeforeEach + void setUp() { + processor = new SummarizeCommandProcessor( + vcsServiceFactory, + summarizeCacheRepository, + aiCommandClient, + tokenEncryptionService + ); + } + + @Test + @DisplayName("should cache fallback content when AI summary is missing") + void shouldCacheFallbackContentWhenAiSummaryIsMissing() throws Exception { + Project project = createProject(); + WebhookPayload payload = createPayload(); + + when(vcsServiceFactory.getReportingService(EVcsProvider.GITHUB)).thenReturn(reportingService); + when(tokenEncryptionService.decrypt("encrypted-ai-key")).thenReturn("ai-key"); + when(tokenEncryptionService.decrypt("encrypted-vcs-token")).thenReturn("vcs-token"); + when(aiCommandClient.summarize( + any(SummarizeRequest.class), + any() + )).thenReturn(new SummarizeResult(null, null, "ASCII")); + + ArgumentCaptor cacheCaptor = ArgumentCaptor.forClass(PrSummarizeCache.class); + when(summarizeCacheRepository.save(cacheCaptor.capture())).thenAnswer(invocation -> { + PrSummarizeCache cache = invocation.getArgument(0); + ReflectionTestUtils.setField(cache, "id", 101L); + return cache; + }); + + Consumer> eventConsumer = event -> {}; + WebhookResult result = processor.process(payload, project, eventConsumer); + + assertThat(result.success()).isTrue(); + assertThat(result.message()).isEqualTo("Summary generated successfully"); + + PrSummarizeCache savedCache = cacheCaptor.getValue(); + assertThat(savedCache.getSummaryContent()).isNotBlank(); + assertThat(savedCache.getSummaryContent()).contains("could not generate a detailed AI summary"); + assertThat(savedCache.getDiagramType()).isEqualTo(PrSummarizeCache.DiagramType.ASCII); + assertThat(result.data().get("content")) + .asString() + .contains("could not generate a detailed AI summary"); + } + + private Project createProject() { + Project project = new Project(); + ReflectionTestUtils.setField(project, "id", 42L); + project.setName("Test Project"); + project.setNamespace("test-project"); + + AIConnection aiConnection = new AIConnection(); + aiConnection.setProviderKey(AIProviderKey.OPENAI); + aiConnection.setAiModel("gpt-4"); + aiConnection.setApiKeyEncrypted("encrypted-ai-key"); + + ProjectAiConnectionBinding aiBinding = new ProjectAiConnectionBinding(); + aiBinding.setProject(project); + aiBinding.setAiConnection(aiConnection); + project.setAiConnectionBinding(aiBinding); + + VcsConnection vcsConnection = new VcsConnection(); + vcsConnection.setProviderType(EVcsProvider.GITHUB); + vcsConnection.setConnectionType(EVcsConnectionType.ACCESS_TOKEN); + vcsConnection.setAccessToken("encrypted-vcs-token"); + + ProjectVcsConnectionBinding vcsBinding = new ProjectVcsConnectionBinding(); + vcsBinding.setProject(project); + vcsBinding.setVcsConnection(vcsConnection); + vcsBinding.setWorkspace("codecrow"); + vcsBinding.setRepoSlug("codecrow-public"); + project.setVcsBinding(vcsBinding); + + return project; + } + + private WebhookPayload createPayload() { + return new WebhookPayload( + EVcsProvider.GITHUB, + "issue_comment", + "repo-id", + "codecrow-public", + "codecrow", + "7", + "feature/summarize", + "main", + "abc123", + null + ); + } +}