diff --git a/pom.xml b/pom.xml
index e549e32..15eb9aa 100644
--- a/pom.xml
+++ b/pom.xml
@@ -69,11 +69,10 @@
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