diff --git a/pom.xml b/pom.xml index e549e32..15eb9aa 100644 --- a/pom.xml +++ b/pom.xml @@ -69,11 +69,10 @@ true - + - org.junit.jupiter - junit-jupiter - 5.10.2 + org.springframework.boot + spring-boot-starter-test test diff --git a/src/main/java/com/devassistant/processor/BuildFailureEventProcessor.java b/src/main/java/com/devassistant/processor/BuildFailureEventProcessor.java index 42a8ab0..19038b1 100644 --- a/src/main/java/com/devassistant/processor/BuildFailureEventProcessor.java +++ b/src/main/java/com/devassistant/processor/BuildFailureEventProcessor.java @@ -1,3 +1,81 @@ package com.devassistant.processor; -public class BuildFailureEventProcessor {} +import com.devassistant.logging.Logger; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.stereotype.Component; + +/** + * Processes incoming Build Failure events and uses AI to explain the failure. + * + *

Educational Note: This processor extracts the raw CI logs from the webhook payload. It + * leverages the LLM as an expert systems engineer to analyze the stack trace or output, identify + * the most likely root cause, assign a confidence score, and suggest fixes. + */ +@Component +public class BuildFailureEventProcessor implements EventProcessor { + + private static final String SUPPORTED_EVENT = "build_failure"; + + private final ObjectMapper objectMapper; + private final Logger logger; + private final ChatClient chatClient; + + public BuildFailureEventProcessor(Logger logger, ChatClient.Builder chatClientBuilder) { + this.objectMapper = new ObjectMapper(); + this.logger = logger; + this.chatClient = chatClientBuilder.build(); + } + + @Override + public boolean supports(String eventType) { + return SUPPORTED_EVENT.equalsIgnoreCase(eventType); + } + + @Override + public void processEvent(String payload) { + try { + JsonNode root = objectMapper.readTree(payload); + JsonNode repoNode = root.path("repository"); + JsonNode logsNode = root.path("logs"); + + if (logsNode.isMissingNode() || repoNode.isMissingNode()) { + logger.warn("Missing logs or repository data in build failure payload."); + return; + } + + String repoName = repoNode.asText(); + String logs = logsNode.asText(); + + // Truncate logs to avoid exceeding LLM context limits (e.g., last 2000 chars) + if (logs.length() > 2000) { + logs = logs.substring(logs.length() - 2000); + } + + String aiPrompt = + String.format( + "You are a Senior DevOps Engineer. Analyze the following CI build failure logs for repository '%s'.\n" + + "Please provide a structured response with exactly these three sections:\n" + + "Likely Cause:\n" + + "Confidence:\n" + + "Possible Fixes:\n\n" + + "Logs:\n%s", + repoName, logs); + + String aiExplanation = "AI Explanation not available"; + try { + aiExplanation = chatClient.prompt().user(aiPrompt).call().content(); + } catch (Exception aiException) { + logger.error("Failed to generate AI explanation for build failure", aiException); + } + + logger.info("=== BUILD FAILURE DETECTED in " + repoName + " ==="); + logger.info("AI Analysis:\n" + aiExplanation); + logger.info("==========================================="); + + } catch (Exception e) { + logger.error("Failed to process build failure event", e); + } + } +} diff --git a/src/test/java/com/devassistant/processor/BuildFailureEventProcessorIT.java b/src/test/java/com/devassistant/processor/BuildFailureEventProcessorIT.java new file mode 100644 index 0000000..eba0569 --- /dev/null +++ b/src/test/java/com/devassistant/processor/BuildFailureEventProcessorIT.java @@ -0,0 +1,194 @@ +package com.devassistant.processor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentCaptor.forClass; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.devassistant.logging.Logger; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +/** + * Integration test for {@link BuildFailureEventProcessor}. + * + *

Educational Note: Aggressive assertions are essential in a good integration test. The goal is + * NOT just to check that the code "doesn't crash" ({@code assertDoesNotThrow}). The goal is to + * verify the exact behaviour of the system: + * + *

+ * + *

This philosophy ensures that if a developer accidentally silences the logger or changes the + * format of the AI prompt, the test will catch it immediately. + */ +@SpringBootTest +class BuildFailureEventProcessorIT { + + private static final String EXPECTED_AI_RESPONSE = + "Likely Cause: NullPointerException in DAO layer\n" + + "Confidence: 95%\n" + + "Possible Fixes: Add null check before the DB call"; + + private static final String REPO_NAME = "devops-notifier"; + + @Autowired private BuildFailureEventProcessor processor; + + @MockBean private ChatClient.Builder chatClientBuilderMock; + + @MockBean private Logger logger; + + private ChatClient chatClientMock; + private ChatClient.ChatClientRequest chatClientRequestMock; + private ChatClient.ChatClientRequest.CallResponseSpec callResponseSpecMock; + + /** + * Wire up the mock chain before each test. Using @BeforeEach avoids duplication and ensures every + * test gets a fresh, predictable mock state. + */ + @BeforeEach + void setUpChatClientMock() { + chatClientMock = mock(ChatClient.class); + chatClientRequestMock = mock(ChatClient.ChatClientRequest.class); + callResponseSpecMock = mock(ChatClient.ChatClientRequest.CallResponseSpec.class); + + when(chatClientBuilderMock.build()).thenReturn(chatClientMock); + when(chatClientMock.prompt()).thenReturn(chatClientRequestMock); + when(chatClientRequestMock.user(anyString())).thenReturn(chatClientRequestMock); + when(chatClientRequestMock.call()).thenReturn(callResponseSpecMock); + when(callResponseSpecMock.content()).thenReturn(EXPECTED_AI_RESPONSE); + } + + @Test + void supports_shouldReturnTrueForBuildFailureEvent() { + assertTrue(processor.supports("build_failure")); + } + + @Test + void supports_shouldBeCaseInsensitive() { + assertTrue(processor.supports("BUILD_FAILURE")); + assertTrue(processor.supports("Build_Failure")); + } + + @Test + void supports_shouldReturnFalseForOtherEventTypes() { + assertFalse(processor.supports("pull_request")); + assertFalse(processor.supports("push")); + assertFalse(processor.supports("unknown_event")); + assertFalse(processor.supports("")); + } + + @Test + void processEvent_shouldCallAIWithPromptContainingRepoNameAndLogs() { + String logSnippet = "ERROR: NullPointerException at com.devassistant.service.SomeService:42"; + String payload = + """ + { + "repository": "%s", + "author": "Agaba-derrick", + "logs": "%s" + } + """ + .formatted(REPO_NAME, logSnippet); + + processor.processEvent(payload); + + // Capture the exact prompt that was sent to the AI and assert its content + ArgumentCaptor promptCaptor = forClass(String.class); + verify(chatClientRequestMock, times(1)).user(promptCaptor.capture()); + + String capturedPrompt = promptCaptor.getValue(); + assertThat(capturedPrompt).contains(REPO_NAME); + assertThat(capturedPrompt).contains(logSnippet); + assertThat(capturedPrompt).containsIgnoringCase("Likely Cause"); + assertThat(capturedPrompt).containsIgnoringCase("Confidence"); + assertThat(capturedPrompt).containsIgnoringCase("Possible Fixes"); + } + + @Test + void processEvent_shouldLogAIResponseContainingRepoNameAndAnalysis() { + String payload = + """ + { + "repository": "%s", + "author": "Agaba-derrick", + "logs": "ERROR: NullPointerException at com.devassistant.service.SomeService:42" + } + """ + .formatted(REPO_NAME); + + processor.processEvent(payload); + + // Capture all info() calls and assert on their exact content + ArgumentCaptor logCaptor = forClass(String.class); + verify(logger, times(3)).info(logCaptor.capture()); + + List allLoggedMessages = logCaptor.getAllValues(); + + // Assert the header log line contains the repository name + assertThat(allLoggedMessages.get(0)).contains("BUILD FAILURE DETECTED").contains(REPO_NAME); + + // Assert the AI analysis log line contains the full AI response text + assertThat(allLoggedMessages.get(1)) + .contains("AI Analysis") + .contains("Likely Cause: NullPointerException in DAO layer") + .contains("Confidence: 95%") + .contains("Possible Fixes: Add null check before the DB call"); + + // Assert the footer separator was logged exactly as defined + assertThat(allLoggedMessages.get(2)).contains("=========="); + } + + @Test + void processEvent_withMissingLogsField_shouldWarnAndNotCallAI() { + // A payload that is missing the required 'logs' field + String malformedPayload = + """ + { + "repository": "devops-notifier" + } + """; + + processor.processEvent(malformedPayload); + + // Assert that a warning was logged with the correct message + ArgumentCaptor warnCaptor = forClass(String.class); + verify(logger, times(1)).warn(warnCaptor.capture()); + assertThat(warnCaptor.getValue()).containsIgnoringCase("missing"); + + // Assert the AI was never called because the payload was invalid + verify(chatClientRequestMock, never()).user(anyString()); + } + + @Test + void processEvent_withInvalidJson_shouldLogErrorAndNotCrash() { + String invalidJson = "this is not valid json {{{{"; + + processor.processEvent(invalidJson); + + // Assert that an error was logged — the processor must not silently swallow the exception + ArgumentCaptor errorCaptor = forClass(String.class); + verify(logger, times(1)) + .error(errorCaptor.capture(), org.mockito.ArgumentMatchers.any(Throwable.class)); + assertThat(errorCaptor.getValue()).containsIgnoringCase("failed"); + + // Assert the AI was never called since parsing failed + verify(chatClientRequestMock, never()).user(anyString()); + } +}